diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5a8893f..f0b1c7c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,5 +1,5 @@ { - "name": "@t3tools/desktop", + "name": "@liteeditor/desktop", "version": "0.0.13", "private": true, "main": "dist-electron/main.js", @@ -23,8 +23,8 @@ "simple-git": "^3.27.0" }, "devDependencies": { - "@t3tools/contracts": "workspace:*", - "@t3tools/shared": "workspace:*", + "@liteeditor/contracts": "workspace:*", + "@liteeditor/shared": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "tsdown": "catalog:", diff --git a/apps/desktop/src/liteeditor/preloadApi.ts b/apps/desktop/src/liteeditor/preloadApi.ts index 42599ad..09a0b23 100644 --- a/apps/desktop/src/liteeditor/preloadApi.ts +++ b/apps/desktop/src/liteeditor/preloadApi.ts @@ -57,7 +57,7 @@ type IntegrationProgress = { message?: string; }; -const commitHash = process.env.T3CODE_COMMIT_HASH ?? "unknown"; +const commitHash = process.env.LITEEDITOR_COMMIT_HASH ?? "unknown"; const buildDate = process.env.BUILD_DATE ?? new Date(0).toISOString(); const homeDir = process.env.USERPROFILE || process.env.HOME || ""; diff --git a/apps/desktop/src/liteeditor/registerLiteEditorDesktop.ts b/apps/desktop/src/liteeditor/registerLiteEditorDesktop.ts index 17811f7..355a978 100644 --- a/apps/desktop/src/liteeditor/registerLiteEditorDesktop.ts +++ b/apps/desktop/src/liteeditor/registerLiteEditorDesktop.ts @@ -344,7 +344,7 @@ function registerBridgeHandlers(options: LiteEditorDesktopRegistrationOptions): links: [ { label: "LiteEditor", - url: "https://github.com/t3tools/t3code", + url: "https://github.com/litesuite/liteeditor", }, ], }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e94516..c45528d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,12 +31,12 @@ import type { DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, -} from "@t3tools/contracts"; +} from "@liteeditor/contracts"; import { autoUpdater } from "electron-updater"; -import type { ContextMenuItem } from "@t3tools/contracts"; -import { NetService } from "@t3tools/shared/Net"; -import { RotatingFileSink } from "@t3tools/shared/logging"; +import type { ContextMenuItem } from "@liteeditor/contracts"; +import { NetService } from "@liteeditor/shared/Net"; +import { RotatingFileSink } from "@liteeditor/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { registerLiteEditorDesktop, @@ -70,9 +70,9 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); +const BASE_DIR = process.env.LITEEDITOR_HOME?.trim() || Path.join(OS.homedir(), ".liteeditor/ProjectData"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); -const DESKTOP_SCHEME = "t3"; +const DESKTOP_SCHEME = "liteeditor"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "LiteEditor (Dev)" : "LiteEditor (Alpha)"; @@ -372,8 +372,8 @@ function resolveEmbeddedCommitHash(): string | null { try { const raw = FS.readFileSync(packageJsonPath, "utf8"); - const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; - return normalizeCommitHash(parsed.t3codeCommitHash); + const parsed = JSON.parse(raw) as { liteeditorCommitHash?: unknown }; + return normalizeCommitHash(parsed.liteeditorCommitHash); } catch { return null; } @@ -384,7 +384,7 @@ function resolveAboutCommitHash(): string | null { return aboutCommitHashCache; } - const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); + const envCommitHash = normalizeCommitHash(process.env.LITEEDITOR_COMMIT_HASH); if (envCommitHash) { aboutCommitHashCache = envCommitHash; return aboutCommitHashCache; @@ -546,7 +546,7 @@ function handleCheckForUpdatesMenuClick(): void { isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + disabledByEnv: process.env.LITEEDITOR_DISABLE_AUTO_UPDATE === "1", }); if (disabledReason) { console.info("[desktop-updater] Manual update check requested, but updates are disabled."); @@ -694,7 +694,7 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { * parentheses (e.g. `~/.config/LiteEditor (Alpha)` on Linux). This is * unfriendly for shell usage and violates Linux naming conventions. * - * We override it to a clean lowercase name (`t3code`). If the legacy + * We override it to a clean lowercase name (`liteeditor`). If the legacy * directory already exists we keep using it so existing users don't * lose their Chromium profile data (localStorage, cookies, sessions). */ @@ -765,7 +765,7 @@ function shouldEnableAutoUpdates(): boolean { isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + disabledByEnv: process.env.LITEEDITOR_DISABLE_AUTO_UPDATE === "1", }) === null ); } @@ -850,7 +850,7 @@ function configureAutoUpdater(): void { updaterConfigured = true; const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; + process.env.LITEEDITOR_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; if (githubToken) { // When a token is provided, re-configure the feed with `private: true` so // electron-updater uses the GitHub API (api.github.com) instead of the @@ -949,11 +949,11 @@ function configureAutoUpdater(): void { function backendEnv(): NodeJS.ProcessEnv { return { ...process.env, - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "1", - T3CODE_PORT: String(backendPort), - T3CODE_HOME: BASE_DIR, - T3CODE_AUTH_TOKEN: backendAuthToken, + LITEEDITOR_MODE: "desktop", + LITEEDITOR_NO_BROWSER: "1", + LITEEDITOR_PORT: String(backendPort), + LITEEDITOR_HOME: BASE_DIR, + LITEEDITOR_AUTH_TOKEN: backendAuthToken, }; } @@ -1391,7 +1391,7 @@ async function bootstrap(): Promise { writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); backendAuthToken = Crypto.randomBytes(24).toString("hex"); backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; - process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; + process.env.LITEEDITOR_DESKTOP_WS_URL = backendWsUrl; writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); registerIpcHandlers(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 794769d..fb6fa30 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge } from "@liteeditor/contracts"; import "./liteeditor/preloadApi"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; @@ -12,7 +12,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; +const wsUrl = process.env.LITEEDITOR_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts index 53dd98a..2059d56 100644 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ b/apps/desktop/src/rotatingFileSink.test.ts @@ -2,13 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { RotatingFileSink } from "@t3tools/shared/logging"; +import { RotatingFileSink } from "@liteeditor/shared/logging"; import { afterEach, describe, expect, it } from "vitest"; const tempRoots: string[] = []; function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "liteeditor-rotating-log-")); tempRoots.push(dir); return dir; } diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts index 127abf5..b823e4c 100644 --- a/apps/desktop/src/runtimeArch.ts +++ b/apps/desktop/src/runtimeArch.ts @@ -1,4 +1,4 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; +import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@liteeditor/contracts"; interface ResolveDesktopRuntimeInfoInput { readonly platform: NodeJS.Platform; diff --git a/apps/desktop/src/src/confirmDialog.test.ts b/apps/desktop/src/src/confirmDialog.test.ts deleted file mode 100644 index 4a4c0dd..0000000 --- a/apps/desktop/src/src/confirmDialog.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { showMessageBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - }, -})); - -import { showDesktopConfirmDialog } from "./confirmDialog"; - -describe("showDesktopConfirmDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - }); - - it("returns false and does not open a dialog for empty messages", async () => { - const result = await showDesktopConfirmDialog(" ", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).not.toHaveBeenCalled(); - }); - - it("opens a dialog for the focused window and returns true on confirm", async () => { - const ownerWindow = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); - - expect(result).toBe(true); - expect(showMessageBoxMock).toHaveBeenCalledWith( - ownerWindow, - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); - - it("opens an app-level dialog when there is no focused window", async () => { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).toHaveBeenCalledWith( - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); -}); diff --git a/apps/desktop/src/src/confirmDialog.ts b/apps/desktop/src/src/confirmDialog.ts deleted file mode 100644 index 1beda85..0000000 --- a/apps/desktop/src/src/confirmDialog.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type BrowserWindow, dialog } from "electron"; - -const CONFIRM_BUTTON_INDEX = 1; - -export async function showDesktopConfirmDialog( - message: string, - ownerWindow: BrowserWindow | null, -): Promise { - const normalizedMessage = message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: CONFIRM_BUTTON_INDEX, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const result = ownerWindow - ? await dialog.showMessageBox(ownerWindow, options) - : await dialog.showMessageBox(options); - return result.response === CONFIRM_BUTTON_INDEX; -} diff --git a/apps/desktop/src/src/liteeditor/claude/claude-preload.ts b/apps/desktop/src/src/liteeditor/claude/claude-preload.ts deleted file mode 100644 index b34bd46..0000000 --- a/apps/desktop/src/src/liteeditor/claude/claude-preload.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck -import { contextBridge, ipcRenderer } from "electron"; - -// Persistent state for the VS Code API shim -let _state: Record = {}; - -// The session ID is passed from the main process after creation -let _sessionId: string | null = null; -let _pendingMessages: any[] = []; - -ipcRenderer.on("claude:set-session-id", (_e, id: string) => { - _sessionId = id; - // Flush any messages that were buffered before the session ID arrived - for (const msg of _pendingMessages) { - ipcRenderer.send("claude:webview-message", id, msg); - } - _pendingMessages = []; -}); - -// Expose acquireVsCodeApi shim -contextBridge.exposeInMainWorld("acquireVsCodeApi", () => ({ - postMessage: (msg: any) => { - if (_sessionId) { - ipcRenderer.send("claude:webview-message", _sessionId, msg); - } else { - // Buffer messages until session ID is set - _pendingMessages.push(msg); - } - }, - getState: () => _state, - setState: (newState: any) => { - _state = { ..._state, ...newState }; - return _state; - }, -})); - -// Host → Webview: dispatch messages from the extension host -ipcRenderer.on("claude:host-message", (_e, message: any) => { - window.postMessage({ type: "from-extension", message }, "*"); -}); diff --git a/apps/desktop/src/src/liteeditor/claude/claude-webview.html b/apps/desktop/src/src/liteeditor/claude/claude-webview.html deleted file mode 100644 index c1cfe45..0000000 --- a/apps/desktop/src/src/liteeditor/claude/claude-webview.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - Claude Code - - - - -
- - diff --git a/apps/desktop/src/src/liteeditor/codex/codex-preload.ts b/apps/desktop/src/src/liteeditor/codex/codex-preload.ts deleted file mode 100644 index 7ec5f73..0000000 --- a/apps/desktop/src/src/liteeditor/codex/codex-preload.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck -import { contextBridge, ipcRenderer } from "electron"; - -// Persistent state for the VS Code API shim -let _state: Record = {}; - -// The session ID is passed from the main process after creation -let _sessionId: string | null = null; -let _pendingMessages: any[] = []; - -ipcRenderer.on("codex:set-session-id", (_e, id: string) => { - _sessionId = id; - // Flush any messages that were buffered before the session ID arrived - for (const msg of _pendingMessages) { - ipcRenderer.send("codex:webview-message", id, msg); - } - _pendingMessages = []; -}); - -// Expose acquireVsCodeApi shim -contextBridge.exposeInMainWorld("acquireVsCodeApi", () => ({ - postMessage: (msg: any) => { - if (_sessionId) { - ipcRenderer.send("codex:webview-message", _sessionId, msg); - } else { - // Buffer messages until session ID is set - _pendingMessages.push(msg); - } - }, - getState: () => _state, - setState: (newState: any) => { - _state = { ..._state, ...newState }; - return _state; - }, -})); - -// Host -> Webview: dispatch messages from the extension host -ipcRenderer.on("codex:host-message", (_e, message: any) => { - window.postMessage(message, "*"); -}); diff --git a/apps/desktop/src/src/liteeditor/ipc/browser-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/browser-handlers.ts deleted file mode 100644 index 606d0e2..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/browser-handlers.ts +++ /dev/null @@ -1,232 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { BrowserManager } from "../services/browser-manager"; -import { type NativeViewBounds } from "../services/native-view-bounds"; -import { - DOM_INDEX_SCRIPT, - getClickScript, - getTypeScript, - getScrollScript, - getSelectOptionScript, -} from "../services/dom-helper"; - -const browserManager = new BrowserManager(); -let activeMainWindow: BrowserWindow | null = null; -let handlersRegistered = false; - -export { browserManager }; - -export function registerBrowserHandlers(mainWindow: BrowserWindow): void { - activeMainWindow = mainWindow; - if (handlersRegistered) { - return; - } - handlersRegistered = true; - - // Lifecycle: create WebContentsView in main process - ipcMain.handle("browser:create-view", async (_e, initialUrl: string) => { - const owner = activeMainWindow; - if (!owner || owner.isDestroyed()) { - throw new Error("Main window unavailable"); - } - return browserManager.createView(owner, initialUrl); - }); - - // Lifecycle: destroy view - ipcMain.on("browser:destroy-view", (_e, sessionId: string) => { - browserManager.destroyView(sessionId); - }); - - // Lifecycle: set view bounds (from renderer ResizeObserver) - ipcMain.on("browser:set-bounds", (_e, sessionId: string, bounds: NativeViewBounds) => { - browserManager.setBounds(sessionId, bounds); - }); - - // Lifecycle: show/hide view (tab visibility) - ipcMain.on("browser:show-view", (_e, sessionId: string) => { - browserManager.showView(sessionId); - }); - - ipcMain.on("browser:hide-view", (_e, sessionId: string) => { - browserManager.hideView(sessionId); - }); - - // Navigation: navigate to URL - ipcMain.handle("browser:navigate", async (_e, sessionId: string, url: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - - // Auto-prepend https:// if no protocol - let targetUrl = url; - if (!/^https?:\/\//i.test(targetUrl) && !targetUrl.startsWith("file://")) { - targetUrl = "https://" + targetUrl; - } - - await wc.loadURL(targetUrl); - return { success: true, url: wc.getURL() }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Navigation: go back - ipcMain.handle("browser:go-back", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - if (!wc.canGoBack()) return { success: false, error: "Cannot go back" }; - wc.goBack(); - return { success: true }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Navigation: go forward - ipcMain.handle("browser:go-forward", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - if (!wc.canGoForward()) return { success: false, error: "Cannot go forward" }; - wc.goForward(); - return { success: true }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Navigation: reload - ipcMain.handle("browser:reload", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - wc.reload(); - return { success: true }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Navigation: stop loading - ipcMain.handle("browser:stop", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - wc.stop(); - return { success: true }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: read page — returns indexed elements + visible text - ipcMain.handle("browser:read-page", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(DOM_INDEX_SCRIPT); - return { success: true, ...result }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: screenshot — returns base64 data URL - ipcMain.handle("browser:screenshot", async (_e, sessionId: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const image = await wc.capturePage(); - const dataUrl = "data:image/png;base64," + image.toPNG().toString("base64"); - return { success: true, dataUrl }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: click element by index - ipcMain.handle("browser:click", async (_e, sessionId: string, index: number) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(getClickScript(index)); - return result; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: type text into element - ipcMain.handle("browser:type", async (_e, sessionId: string, text: string, index?: number) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(getTypeScript(text, index)); - return result; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: scroll page - ipcMain.handle( - "browser:scroll", - async (_e, sessionId: string, direction: "up" | "down" | "left" | "right", amount: number) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(getScrollScript(direction, amount)); - return result; - } catch (err) { - return { success: false, error: String(err) }; - } - }, - ); - - // Agent tool: select dropdown option - ipcMain.handle( - "browser:select-option", - async (_e, sessionId: string, elementIndex: number, optionIndex: number) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(getSelectOptionScript(elementIndex, optionIndex)); - return result; - } catch (err) { - return { success: false, error: String(err) }; - } - }, - ); - - // Agent tool: execute arbitrary JavaScript - ipcMain.handle("browser:execute-js", async (_e, sessionId: string, code: string) => { - try { - const wc = browserManager.getWebContents(sessionId); - if (!wc) return { success: false, error: "Session not found" }; - const result = await wc.executeJavaScript(code); - return { success: true, result }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: get console logs - ipcMain.handle("browser:console-logs", async (_e, sessionId: string, since?: number) => { - try { - const logs = browserManager.getConsoleLogs(sessionId, since); - return { success: true, logs }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - // Agent tool: list all active sessions - ipcMain.handle("browser:list-sessions", async () => { - return browserManager.listSessions(); - }); -} - -export function shutdownBrowserHandlers(): void { - browserManager.removeAll(); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/claude-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/claude-handlers.ts deleted file mode 100644 index d3e7c0a..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/claude-handlers.ts +++ /dev/null @@ -1,127 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { ClaudeManager } from "../services/claude-manager"; -import { ClaudeBridge } from "../services/claude-bridge"; -import { ptyManager } from "./pty-handlers"; -import { type NativeViewBounds } from "../services/native-view-bounds"; - -const claudeManager = new ClaudeManager(); -let activeMainWindow: BrowserWindow | null = null; - -interface PendingHostOp { - resolve: (payload: Record) => void; - reject: (err: Error) => void; - timer: NodeJS.Timeout; -} - -const pendingHostOps = new Map(); -let handlersRegistered = false; - -async function invokeRendererHostOp( - op: string, - payload: Record = {}, -): Promise> { - if (!activeMainWindow || activeMainWindow.isDestroyed()) return {}; - - const requestId = `claude-host-op-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingHostOps.delete(requestId); - reject(new Error(`Timed out waiting for renderer op '${op}'`)); - }, 15000); - - pendingHostOps.set(requestId, { resolve, reject, timer }); - activeMainWindow?.webContents.send("claude:host-op", { - id: requestId, - op, - payload, - }); - }); -} - -const claudeBridge = new ClaudeBridge(claudeManager, { - ptyManager, - invokeRendererOp: invokeRendererHostOp, -}); - -export { claudeManager }; - -export function registerClaudeHandlers(mainWindow: BrowserWindow): void { - activeMainWindow = mainWindow; - if (handlersRegistered) { - return; - } - handlersRegistered = true; - - // Lifecycle: create Claude WebContentsView - ipcMain.handle("claude:create-session", async () => { - const owner = activeMainWindow; - if (!owner || owner.isDestroyed()) { - throw new Error("Main window unavailable"); - } - return claudeManager.createSession(owner); - }); - - // Lifecycle: destroy session - ipcMain.on("claude:destroy-session", (_e, sessionId: string) => { - claudeBridge.shutdownSession(sessionId); - claudeManager.destroySession(sessionId); - }); - - // Lifecycle: set view bounds (from renderer rAF loop) - ipcMain.on("claude:set-bounds", (_e, sessionId: string, bounds: NativeViewBounds) => { - claudeManager.setBounds(sessionId, bounds); - }); - - // Lifecycle: show/hide view (tab visibility) - ipcMain.on("claude:show-view", (_e, sessionId: string) => { - claudeManager.showView(sessionId); - claudeBridge.notifyVisibilityChanged(sessionId, true); - }); - - ipcMain.on("claude:hide-view", (_e, sessionId: string) => { - claudeManager.hideView(sessionId); - claudeBridge.notifyVisibilityChanged(sessionId, false); - }); - - // Message bridge: webview -> host -> claude.exe - ipcMain.on("claude:webview-message", (_e, sessionId: string, message: any) => { - claudeBridge.handleWebviewMessage(sessionId, message); - }); - - ipcMain.on("claude:host-op-result", (_e, result: any) => { - const requestId = typeof result?.id === "string" ? result.id : null; - if (!requestId) return; - - const pending = pendingHostOps.get(requestId); - if (!pending) return; - - clearTimeout(pending.timer); - pendingHostOps.delete(requestId); - - if (result?.ok) { - const payload = - result?.payload && typeof result.payload === "object" - ? (result.payload as Record) - : {}; - pending.resolve(payload); - return; - } - - const errorText = typeof result?.error === "string" ? result.error : "Renderer host op failed"; - pending.reject(new Error(errorText)); - }); -} - -export function shutdownClaudeHandlers(): void { - pendingHostOps.forEach((pending, requestId) => { - clearTimeout(pending.timer); - pending.reject(new Error(`Cancelled renderer op '${requestId}' during shutdown`)); - }); - pendingHostOps.clear(); - activeMainWindow = null; - - claudeBridge.shutdown(); - claudeManager.removeAll(); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/codex-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/codex-handlers.ts deleted file mode 100644 index 5a8d2d0..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/codex-handlers.ts +++ /dev/null @@ -1,62 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { CodexManager } from "../services/codex-manager"; -import { CodexBridge } from "../services/codex-bridge"; -import { type NativeViewBounds } from "../services/native-view-bounds"; - -const codexManager = new CodexManager(); -const codexBridge = new CodexBridge(codexManager); -let activeMainWindow: BrowserWindow | null = null; -let handlersRegistered = false; - -export function registerCodexHandlers(mainWindow: BrowserWindow): void { - activeMainWindow = mainWindow; - if (handlersRegistered) { - return; - } - handlersRegistered = true; - - // Lifecycle: create Codex WebContentsView - ipcMain.handle("codex:create-session", async () => { - const owner = activeMainWindow; - if (!owner || owner.isDestroyed()) { - throw new Error("Main window unavailable"); - } - return codexManager.createSession(owner); - }); - - // Lifecycle: destroy session - ipcMain.on("codex:destroy-session", (_e, sessionId: string) => { - codexBridge.removeSession(sessionId); - codexManager.destroySession(sessionId); - }); - - // Lifecycle: set view bounds (from renderer rAF loop) - ipcMain.on("codex:set-bounds", (_e, sessionId: string, bounds: NativeViewBounds) => { - codexManager.setBounds(sessionId, bounds); - }); - - // Lifecycle: show/hide view (tab visibility) - ipcMain.on("codex:show-view", (_e, sessionId: string) => { - codexManager.showView(sessionId); - }); - - ipcMain.on("codex:hide-view", (_e, sessionId: string) => { - codexManager.hideView(sessionId); - }); - - // Workspace context sync from renderer (project root) - ipcMain.on("codex:set-project-root", (_e, projectRoot: string | null) => { - codexBridge.setProjectRoot(typeof projectRoot === "string" ? projectRoot : null); - }); - - // Message bridge: webview -> host - ipcMain.on("codex:webview-message", (_e, sessionId: string, message: any) => { - codexBridge.handleWebviewMessage(sessionId, message); - }); -} - -export function shutdownCodexHandlers(): void { - codexBridge.shutdown(); - codexManager.removeAll(); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/fs-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/fs-handlers.ts deleted file mode 100644 index 4b8ca49..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/fs-handlers.ts +++ /dev/null @@ -1,85 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { mkdir, readFile, rm, writeFile } from "fs/promises"; -import { FileWatcher } from "../services/file-watcher"; -import { FileTreeDB } from "../services/file-tree-db"; - -let fileWatcher: FileWatcher | null = null; -let fileTreeDB: FileTreeDB | null = null; -let currentRoot: string | null = null; - -function onFileChange(event: string, filePath: string): void { - if (fileTreeDB) { - fileTreeDB.invalidatePath(filePath); - } - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - win.webContents.send("fs:file-changed", event, filePath); - } -} - -export function registerFsHandlers(): void { - fileTreeDB = new FileTreeDB(); - - ipcMain.handle("fs:read-file", async (_e, path: string) => { - return readFile(path, "utf-8"); - }); - - ipcMain.handle("fs:write-file", async (_e, path: string, content: string) => { - await writeFile(path, content, "utf-8"); - }); - - ipcMain.handle("fs:ensure-dir", async (_e, path: string) => { - await mkdir(path, { recursive: true }); - }); - - ipcMain.handle("fs:delete-file", async (_e, path: string) => { - await rm(path, { force: true }); - }); - - ipcMain.handle("fs:read-tree", async (_e, root: string, _depth?: number) => { - if (!fileTreeDB) return []; - if (root !== currentRoot) { - fileTreeDB.clear(); - currentRoot = root; - } - return fileTreeDB.initRoot(root); - }); - - ipcMain.handle("fs:read-dir", async (_e, dirPath: string) => { - if (!fileTreeDB) return []; - return fileTreeDB.getChildren(dirPath); - }); - - ipcMain.on("fs:watch-start", (_e, path: string) => { - if (fileWatcher) { - fileWatcher.closeAll(); - } - fileWatcher = new FileWatcher(onFileChange); - fileWatcher.watchDir(path); - }); - - ipcMain.on("fs:watch-dir", (_e, dirPath: string) => { - if (fileWatcher) { - fileWatcher.watchDir(dirPath); - } - }); - - ipcMain.on("fs:unwatch-dir", (_e, dirPath: string) => { - if (fileWatcher) { - fileWatcher.unwatchDir(dirPath); - } - }); - - ipcMain.on("fs:watch-stop", () => { - if (fileWatcher) { - fileWatcher.closeAll(); - fileWatcher = null; - } - }); -} - -export function shutdownFsHandlers(): void { - fileWatcher?.closeAll(); - fileTreeDB?.close(); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/git-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/git-handlers.ts deleted file mode 100644 index 3d78da8..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/git-handlers.ts +++ /dev/null @@ -1,138 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { GitService } from "../services/git-service"; - -let gitService: GitService | null = null; - -function getGit(): GitService { - if (!gitService) throw new Error("Git service not initialized"); - return gitService; -} - -export function registerGitHandlers(): void { - // Initialize git service when project root is set - ipcMain.handle("git:init", async (_e, root: string) => { - gitService = new GitService(root); - }); - - // Get current branch for an arbitrary path (used by sidebar per-project) - ipcMain.handle("git:current-branch-for-path", async (_e, rootPath: string) => { - const tempGit = new GitService(rootPath); - return tempGit.currentBranch(); - }); - - ipcMain.handle("git:status", async () => { - return getGit().status(); - }); - - ipcMain.handle("git:diff", async (_e, path: string) => { - return getGit().diff(path); - }); - - ipcMain.handle("git:diff-cached", async (_e, path: string) => { - return getGit().diffCached(path); - }); - - ipcMain.handle("git:stage", async (_e, path: string) => { - return getGit().stage(path); - }); - - ipcMain.handle("git:stage-all", async () => { - return getGit().stageAll(); - }); - - ipcMain.handle("git:unstage", async (_e, path: string) => { - return getGit().unstage(path); - }); - - ipcMain.handle("git:unstage-all", async () => { - return getGit().unstageAll(); - }); - - ipcMain.handle("git:commit", async (_e, summary: string, description?: string) => { - const message = description ? `${summary}\n\n${description}` : summary; - return getGit().commit(message); - }); - - ipcMain.handle("git:push", async () => { - return getGit().push(); - }); - - ipcMain.handle("git:pull", async () => { - return getGit().pull(); - }); - - ipcMain.handle("git:fetch", async () => { - return getGit().fetch(); - }); - - ipcMain.handle("git:log", async (_e, limit?: number) => { - return getGit().log(limit); - }); - - ipcMain.handle("git:branches", async () => { - return getGit().branches(); - }); - - ipcMain.handle("git:current-branch", async () => { - return getGit().currentBranch(); - }); - - ipcMain.handle("git:checkout", async (_e, name: string) => { - return getGit().checkout(name); - }); - - ipcMain.handle("git:create-branch", async (_e, name: string) => { - return getGit().createBranch(name); - }); - - ipcMain.handle("git:delete-branch", async (_e, name: string, force?: boolean) => { - return getGit().deleteBranch(name, force); - }); - - ipcMain.handle("git:file-at-revision", async (_e, path: string, rev: string) => { - return getGit().getFileAtRevision(path, rev); - }); - - ipcMain.handle("git:discard-changes", async (_e, path: string) => { - return getGit().discardChanges(path); - }); - - ipcMain.handle("git:show-commit", async (_e, hash: string) => { - return getGit().showCommit(hash); - }); - - ipcMain.handle("git:diff-commit-file", async (_e, hash: string, path: string) => { - return getGit().diffCommitFile(hash, path); - }); - - ipcMain.handle("git:status-porcelain", async () => { - return getGit().statusPorcelain(); - }); - - // Worktree operations - ipcMain.handle("git:worktree-list", async () => { - return getGit().worktreeList(); - }); - - ipcMain.handle( - "git:worktree-add", - async (_e, path: string, branch: string, createBranch: boolean) => { - return getGit().worktreeAdd(path, branch, createBranch); - }, - ); - - ipcMain.handle("git:worktree-remove", async (_e, path: string) => { - return getGit().worktreeRemove(path); - }); - - ipcMain.handle("git:branch-list", async () => { - return getGit().branchList(); - }); - - // Get porcelain status for an arbitrary path (used by sidebar per-project dirty indicator) - ipcMain.handle("git:status-porcelain-for-path", async (_e, rootPath: string) => { - const tempGit = new GitService(rootPath); - return tempGit.statusPorcelain(); - }); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/github-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/github-handlers.ts deleted file mode 100644 index af1beda..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/github-handlers.ts +++ /dev/null @@ -1,135 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { GitHubService } from "../services/github-service"; - -const github = new GitHubService(); - -export function registerGitHubHandlers(): void { - // ── CLI status ── - ipcMain.handle("github:check-cli", async () => { - return github.checkCli(); - }); - - ipcMain.handle("github:install-cli", async () => { - return github.installCli(); - }); - - ipcMain.handle("github:set-cwd", async (_e, cwd: string) => { - github.setCwd(cwd); - }); - - // ── Repo ── - ipcMain.handle("github:repo-info", async () => { - return github.repoInfo(); - }); - - // ── Pull Requests ── - ipcMain.handle("github:pr-list", async (_e, state?: string) => { - return github.prList(state); - }); - - ipcMain.handle("github:pr-get", async (_e, number: number) => { - return github.prGet(number); - }); - - ipcMain.handle("github:pr-diff", async (_e, number: number) => { - return github.prDiff(number); - }); - - ipcMain.handle( - "github:pr-create", - async ( - _e, - title: string, - body: string, - base: string, - head: string, - reviewers?: string[], - labels?: string[], - ) => { - return github.prCreate(title, body, base, head, reviewers, labels); - }, - ); - - ipcMain.handle( - "github:pr-merge", - async (_e, number: number, method: "squash" | "rebase" | "merge", deleteBranch?: boolean) => { - return github.prMerge(number, method, deleteBranch); - }, - ); - - ipcMain.handle("github:pr-close", async (_e, number: number) => { - return github.prClose(number); - }); - - ipcMain.handle("github:pr-reviews", async (_e, number: number) => { - return github.prReviews(number); - }); - - ipcMain.handle("github:pr-review-comments", async (_e, number: number) => { - return github.prReviewComments(number); - }); - - ipcMain.handle( - "github:pr-review-submit", - async ( - _e, - number: number, - event: string, - body: string, - comments?: Array<{ path: string; line: number; body: string }>, - ) => { - return github.prSubmitReview( - number, - event as "APPROVE" | "REQUEST_CHANGES" | "COMMENT", - body, - comments, - ); - }, - ); - - // ── Issues ── - ipcMain.handle("github:issue-list", async (_e, state?: string) => { - return github.issueList(state); - }); - - ipcMain.handle("github:issue-get", async (_e, number: number) => { - return github.issueGet(number); - }); - - ipcMain.handle( - "github:issue-create", - async (_e, title: string, body: string, labels?: string[], assignees?: string[]) => { - return github.issueCreate(title, body, labels, assignees); - }, - ); - - ipcMain.handle("github:issue-comment", async (_e, number: number, body: string) => { - return github.issueComment(number, body); - }); - - ipcMain.handle("github:issue-close", async (_e, number: number) => { - return github.issueClose(number); - }); - - ipcMain.handle("github:issue-reopen", async (_e, number: number) => { - return github.issueReopen(number); - }); - - ipcMain.handle("github:issue-comments", async (_e, number: number) => { - return github.issueComments(number); - }); - - // ── Helpers ── - ipcMain.handle("github:labels-list", async () => { - return github.labelsList(); - }); - - ipcMain.handle("github:collaborators-list", async () => { - return github.collaboratorsList(); - }); - - ipcMain.handle("github:pr-template", async () => { - return github.prTemplate(); - }); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/integrations-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/integrations-handlers.ts deleted file mode 100644 index e6f8915..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/integrations-handlers.ts +++ /dev/null @@ -1,85 +0,0 @@ -// @ts-nocheck -import { BrowserWindow, ipcMain, shell } from "electron"; -import { integrationsManager } from "../services/integrations-manager"; -import { type IntegrationId, type IntegrationProgress } from "../services/integrations-types"; - -let progressListener: ((event: IntegrationProgress) => void) | null = null; -let activeMainWindow: BrowserWindow | null = null; -let handlersRegistered = false; - -function asIntegrationId(value: unknown): IntegrationId { - if (value === "codex" || value === "claude") return value; - throw new Error(`Unknown integration id: ${String(value)}`); -} - -export function registerIntegrationsHandlers(mainWindow: BrowserWindow): void { - activeMainWindow = mainWindow; - - if (!progressListener) { - progressListener = (progress) => { - if (activeMainWindow && !activeMainWindow.isDestroyed()) { - activeMainWindow.webContents.send("integrations:progress", progress); - } - }; - integrationsManager.on("progress", progressListener); - } - - if (handlersRegistered) { - return; - } - handlersRegistered = true; - - ipcMain.handle("integrations:list-status", async () => integrationsManager.listStatus()); - - ipcMain.handle("integrations:check-updates", async (_e, integrationId?: IntegrationId) => { - if (integrationId !== undefined) { - return integrationsManager.checkUpdates(asIntegrationId(integrationId)); - } - return integrationsManager.checkUpdates(); - }); - - ipcMain.handle( - "integrations:install", - async (_e, integrationId: IntegrationId, options?: { reinstall?: boolean }) => { - return integrationsManager.install(asIntegrationId(integrationId), options ?? {}); - }, - ); - - ipcMain.handle("integrations:update", async (_e, integrationId: IntegrationId) => { - return integrationsManager.update(asIntegrationId(integrationId)); - }); - - ipcMain.handle("integrations:verify", async (_e, integrationId: IntegrationId) => { - return integrationsManager.verify(asIntegrationId(integrationId)); - }); - - ipcMain.handle("integrations:reveal-path", async (_e, integrationId: IntegrationId) => { - const path = integrationsManager.revealPath(asIntegrationId(integrationId)); - if (path) { - await shell.openPath(path); - } - return path; - }); - - ipcMain.handle("integrations:reveal-log", async (_e, integrationId: IntegrationId) => { - const path = integrationsManager.revealLog(asIntegrationId(integrationId)); - await shell.openPath(path); - return path; - }); -} - -export function shutdownIntegrationsHandlers(): void { - ipcMain.removeHandler("integrations:list-status"); - ipcMain.removeHandler("integrations:check-updates"); - ipcMain.removeHandler("integrations:install"); - ipcMain.removeHandler("integrations:update"); - ipcMain.removeHandler("integrations:verify"); - ipcMain.removeHandler("integrations:reveal-path"); - ipcMain.removeHandler("integrations:reveal-log"); - - if (progressListener) { - integrationsManager.off("progress", progressListener); - progressListener = null; - } - activeMainWindow = null; -} diff --git a/apps/desktop/src/src/liteeditor/ipc/project-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/project-handlers.ts deleted file mode 100644 index ebfce5c..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/project-handlers.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { ProjectService } from "../services/project-service"; -import { ScriptDetectionService } from "../services/script-detection-service"; - -const projectService = new ProjectService(); -const scriptDetection = new ScriptDetectionService(); - -export function registerProjectHandlers(): void { - ipcMain.handle("project:list", async () => { - return projectService.loadProjects(); - }); - - ipcMain.handle("project:add", async (_e, rootPath: string, name?: string) => { - return projectService.addProject(rootPath, name); - }); - - ipcMain.handle("project:remove", async (_e, id: string) => { - return projectService.removeProject(id); - }); - - ipcMain.handle("project:update", async (_e, id: string, updates: Record) => { - return projectService.updateProject(id, updates); - }); - - ipcMain.handle("project:get", async (_e, id: string) => { - return projectService.getProject(id); - }); - ipcMain.handle("project:detect-scripts", async (_e, rootPath: string) => { - return scriptDetection.detectScripts(rootPath); - }); -} - -export { projectService }; diff --git a/apps/desktop/src/src/liteeditor/ipc/pty-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/pty-handlers.ts deleted file mode 100644 index 89f97cf..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/pty-handlers.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { PtyManager } from "../services/pty-manager"; - -const ptyManager = new PtyManager(); - -export { ptyManager }; - -export function shutdownPtyHandlers(): void { - ptyManager.killAll(); -} - -export function registerPtyHandlers(): void { - ipcMain.handle("pty:create", async (_e, shell?: string, cwd?: string) => { - const sessionId = ptyManager.create( - shell, - cwd, - (data) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - win.webContents.send(`pty:data:${sessionId}`, data); - } - }, - (exitCode) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - win.webContents.send(`pty:exit:${sessionId}`, exitCode); - } - }, - ); - return sessionId; - }); - - ipcMain.on("pty:write", (_e, sessionId: string, data: string) => { - ptyManager.write(sessionId, data); - }); - - ipcMain.on("pty:resize", (_e, sessionId: string, cols: number, rows: number) => { - ptyManager.resize(sessionId, cols, rows); - }); - - ipcMain.on("pty:kill", (_e, sessionId: string) => { - ptyManager.kill(sessionId); - }); - - ipcMain.handle("pty:get-session-info", async (_e, sessionId: string) => { - return ptyManager.getSessionInfo(sessionId); - }); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/script-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/script-handlers.ts deleted file mode 100644 index 8aae971..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/script-handlers.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-nocheck -import { ipcMain, BrowserWindow } from "electron"; -import { ScriptDetectionService } from "../services/script-detection-service"; -import { DevServerManager } from "../services/dev-server-manager"; - -const scriptDetection = new ScriptDetectionService(); -const devServerManager = new DevServerManager(); - -export { devServerManager }; - -export function registerScriptHandlers(): void { - ipcMain.handle("scripts:detect", async (_e, rootPath: string) => { - return scriptDetection.detectScripts(rootPath); - }); - - ipcMain.handle( - "scripts:start", - async (_e, projectId: string, scriptName: string, cwd: string) => { - return devServerManager.startScript(projectId, scriptName, cwd); - }, - ); - - ipcMain.handle("scripts:stop", async (_e, sessionId: string) => { - devServerManager.stopScript(sessionId); - }); - - ipcMain.handle("scripts:running", async () => { - return devServerManager.getRunningScripts(); - }); - - // Forward status change events to all renderer windows - devServerManager.onStatusChange((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - win.webContents.send("scripts:status-changed", event); - } - }); -} - -export function shutdownScriptHandlers(): void { - devServerManager.killAll(); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/search-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/search-handlers.ts deleted file mode 100644 index f45966c..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/search-handlers.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { SearchService, type SearchOptions } from "../services/search-service"; - -let searchService: SearchService | null = null; - -export function registerSearchHandlers(): void { - ipcMain.handle("search:set-root", async (_e, root: string) => { - searchService = new SearchService(root); - }); - - ipcMain.handle("search:files", async (_e, query: string, options: SearchOptions) => { - if (!searchService) return []; - return searchService.search(query, options); - }); -} diff --git a/apps/desktop/src/src/liteeditor/ipc/shell-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/shell-handlers.ts deleted file mode 100644 index 8851af2..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/shell-handlers.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-nocheck -// Shell handlers are registered directly in main/index.ts -// This file is kept for organizational reference -export {}; diff --git a/apps/desktop/src/src/liteeditor/ipc/workspace-crud-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/workspace-crud-handlers.ts deleted file mode 100644 index 5beec25..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/workspace-crud-handlers.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { WorkspacePersistenceService } from "../services/workspace-persistence-service"; - -const workspacePersistence = new WorkspacePersistenceService(); - -export function registerWorkspaceCrudHandlers(): void { - ipcMain.handle("workspaces:list", async (_e, projectId: string) => { - return workspacePersistence.listWorkspaces(projectId); - }); - - ipcMain.handle("workspaces:load", async (_e, projectId: string, workspaceId: string) => { - return workspacePersistence.loadWorkspace(projectId, workspaceId); - }); - - ipcMain.handle("workspaces:save", async (_e, projectId: string, workspace: string) => { - return workspacePersistence.saveWorkspace(projectId, JSON.parse(workspace)); - }); - - ipcMain.handle( - "workspaces:create", - async ( - _e, - projectId: string, - name: string, - type?: string, - branch?: string, - worktreePath?: string, - ) => { - return workspacePersistence.createWorkspace( - projectId, - name, - (type as "local" | "worktree") || "local", - branch, - worktreePath, - ); - }, - ); - - ipcMain.handle("workspaces:delete", async (_e, projectId: string, workspaceId: string) => { - return workspacePersistence.deleteWorkspace(projectId, workspaceId); - }); - - ipcMain.handle( - "workspaces:rename", - async (_e, projectId: string, workspaceId: string, name: string) => { - return workspacePersistence.renameWorkspace(projectId, workspaceId, name); - }, - ); -} - -export { workspacePersistence }; diff --git a/apps/desktop/src/src/liteeditor/ipc/workspace-handlers.ts b/apps/desktop/src/src/liteeditor/ipc/workspace-handlers.ts deleted file mode 100644 index 3b85eaf..0000000 --- a/apps/desktop/src/src/liteeditor/ipc/workspace-handlers.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -import { ipcMain } from "electron"; -import { readFile, writeFile, mkdir } from "fs/promises"; -import { join } from "path"; -import { homedir } from "os"; -import { WorkspaceService } from "../services/workspace-service"; - -const workspaceService = new WorkspaceService(); - -export function registerWorkspaceHandlers(): void { - const settingsDir = join(homedir(), ".liteeditor"); - const workspaceFile = join(settingsDir, "workspace.json"); - const settingsFile = join(settingsDir, "settings.json"); - - // Global workspace (unchanged — stores projectRoot + zoomLevel) - ipcMain.handle("workspace:load", async () => { - try { - const content = await readFile(workspaceFile, "utf-8"); - return JSON.parse(content); - } catch { - return null; - } - }); - - ipcMain.handle("workspace:save", async (_e, data: string) => { - try { - await mkdir(settingsDir, { recursive: true }); - await writeFile(workspaceFile, data, "utf-8"); - } catch { - /* ignore */ - } - }); - - // Global settings - ipcMain.handle("settings:load", async () => { - try { - const content = await readFile(settingsFile, "utf-8"); - return JSON.parse(content); - } catch { - return null; - } - }); - - ipcMain.handle("settings:save", async (_e, data: string) => { - try { - await mkdir(settingsDir, { recursive: true }); - await writeFile(settingsFile, data, "utf-8"); - } catch { - /* ignore */ - } - }); - - // Per-project workspace state - ipcMain.handle("workspace:load-state", async (_e, projectRoot: string) => { - return workspaceService.loadState(projectRoot); - }); - - ipcMain.handle("workspace:save-state", async (_e, projectRoot: string, state: string) => { - return workspaceService.saveState(projectRoot, state); - }); - - // Per-project workspace settings - ipcMain.handle("workspace:load-settings", async (_e, projectRoot: string) => { - return workspaceService.loadSettings(projectRoot); - }); - - ipcMain.handle("workspace:save-settings", async (_e, projectRoot: string, data: string) => { - return workspaceService.saveSettings(projectRoot, data); - }); -} diff --git a/apps/desktop/src/src/liteeditor/preloadApi.ts b/apps/desktop/src/src/liteeditor/preloadApi.ts deleted file mode 100644 index 42599ad..0000000 --- a/apps/desktop/src/src/liteeditor/preloadApi.ts +++ /dev/null @@ -1,495 +0,0 @@ -// @ts-nocheck -import { contextBridge, ipcRenderer, webFrame } from "electron"; - -type NativeViewBounds = { - x: number; - y: number; - width: number; - height: number; - viewportWidth?: number; - viewportHeight?: number; -}; - -type PtySessionInfo = { - id: string; - pid: number; - shell: string; - cwd: string; - createdAt: number; -}; - -type IntegrationId = "codex" | "claude"; - -type IntegrationState = - | "not_installed" - | "installed_managed" - | "installed_external" - | "update_available" - | "broken" - | "verifying" - | "downloading" - | "installing" - | "failed"; - -type IntegrationStatus = { - id: IntegrationId; - state: IntegrationState; - installedVersion: string | null; - latestVersion: string | null; - source: "managed" | "external" | null; - verified: boolean; - lastVerifiedAt: number | null; - message?: string; -}; - -type IntegrationProgress = { - id: IntegrationId; - stage: - | "checking" - | "downloading" - | "verifying" - | "extracting" - | "installing" - | "finalizing" - | "done" - | "error"; - percent?: number; - message?: string; -}; - -const commitHash = process.env.T3CODE_COMMIT_HASH ?? "unknown"; -const buildDate = process.env.BUILD_DATE ?? new Date(0).toISOString(); -const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - -const api = { - appInfo: { - version: process.env.npm_package_version ?? "0.0.0", - commitHash, - buildDate, - electronVersion: process.versions.electron, - nodeVersion: process.versions.node, - platform: process.platform, - homeDir, - }, - - fs: { - readFile: (path: string): Promise => ipcRenderer.invoke("fs:read-file", path), - writeFile: (path: string, content: string): Promise => - ipcRenderer.invoke("fs:write-file", path, content), - ensureDir: (path: string): Promise => ipcRenderer.invoke("fs:ensure-dir", path), - deleteFile: (path: string): Promise => ipcRenderer.invoke("fs:delete-file", path), - readTree: (root: string, depth?: number): Promise => - ipcRenderer.invoke("fs:read-tree", root, depth), - readDir: (dirPath: string): Promise => ipcRenderer.invoke("fs:read-dir", dirPath), - watchStart: (path: string): void => ipcRenderer.send("fs:watch-start", path), - watchStop: (): void => ipcRenderer.send("fs:watch-stop"), - watchDir: (path: string): void => ipcRenderer.send("fs:watch-dir", path), - unwatchDir: (path: string): void => ipcRenderer.send("fs:unwatch-dir", path), - onFileChange: (callback: (event: string, path: string) => void): (() => void) => { - const handler = (_e: unknown, event: string, path: string) => callback(event, path); - ipcRenderer.on("fs:file-changed", handler); - return () => ipcRenderer.removeListener("fs:file-changed", handler); - }, - showInExplorer: (path: string): void => ipcRenderer.send("shell:open-path", path), - }, - - git: { - init: (root: string): Promise => ipcRenderer.invoke("git:init", root), - status: (): Promise => ipcRenderer.invoke("git:status"), - diff: (path: string): Promise => ipcRenderer.invoke("git:diff", path), - diffCached: (path: string): Promise => ipcRenderer.invoke("git:diff-cached", path), - stage: (path: string): Promise => ipcRenderer.invoke("git:stage", path), - stageAll: (): Promise => ipcRenderer.invoke("git:stage-all"), - unstage: (path: string): Promise => ipcRenderer.invoke("git:unstage", path), - unstageAll: (): Promise => ipcRenderer.invoke("git:unstage-all"), - commit: (summary: string, description?: string): Promise => - ipcRenderer.invoke("git:commit", summary, description), - push: (): Promise => ipcRenderer.invoke("git:push"), - pull: (): Promise => ipcRenderer.invoke("git:pull"), - fetch: (): Promise => ipcRenderer.invoke("git:fetch"), - log: (limit?: number): Promise => ipcRenderer.invoke("git:log", limit), - branches: (): Promise => ipcRenderer.invoke("git:branches"), - currentBranch: (): Promise => ipcRenderer.invoke("git:current-branch"), - currentBranchForPath: (rootPath: string): Promise => - ipcRenderer.invoke("git:current-branch-for-path", rootPath), - checkout: (name: string): Promise => ipcRenderer.invoke("git:checkout", name), - createBranch: (name: string): Promise => ipcRenderer.invoke("git:create-branch", name), - deleteBranch: (name: string, force?: boolean): Promise => - ipcRenderer.invoke("git:delete-branch", name, force), - getFileAtRevision: (path: string, rev: string): Promise => - ipcRenderer.invoke("git:file-at-revision", path, rev), - discardChanges: (path: string): Promise => - ipcRenderer.invoke("git:discard-changes", path), - showCommit: (hash: string): Promise => ipcRenderer.invoke("git:show-commit", hash), - diffCommitFile: (hash: string, path: string): Promise => - ipcRenderer.invoke("git:diff-commit-file", hash, path), - statusPorcelain: (): Promise => ipcRenderer.invoke("git:status-porcelain"), - worktreeList: (): Promise => ipcRenderer.invoke("git:worktree-list"), - worktreeAdd: (path: string, branch: string, createBranch: boolean): Promise => - ipcRenderer.invoke("git:worktree-add", path, branch, createBranch), - worktreeRemove: (path: string): Promise => - ipcRenderer.invoke("git:worktree-remove", path), - branchList: (): Promise => ipcRenderer.invoke("git:branch-list"), - statusPorcelainForPath: (rootPath: string): Promise => - ipcRenderer.invoke("git:status-porcelain-for-path", rootPath), - }, - - pty: { - create: (shell?: string, cwd?: string): Promise => - ipcRenderer.invoke("pty:create", shell, cwd), - getSessionInfo: (sessionId: string): Promise => - ipcRenderer.invoke("pty:get-session-info", sessionId), - write: (sessionId: string, data: string): void => - ipcRenderer.send("pty:write", sessionId, data), - resize: (sessionId: string, cols: number, rows: number): void => - ipcRenderer.send("pty:resize", sessionId, cols, rows), - kill: (sessionId: string): void => ipcRenderer.send("pty:kill", sessionId), - onData: (sessionId: string, callback: (data: string) => void): (() => void) => { - const channel = `pty:data:${sessionId}`; - const handler = (_e: unknown, data: string) => callback(data); - ipcRenderer.on(channel, handler); - return () => ipcRenderer.removeListener(channel, handler); - }, - onExit: (sessionId: string, callback: (exitCode: number) => void): (() => void) => { - const channel = `pty:exit:${sessionId}`; - const handler = (_e: unknown, exitCode: number) => callback(exitCode); - ipcRenderer.on(channel, handler); - return () => ipcRenderer.removeListener(channel, handler); - }, - }, - - search: { - setRoot: (root: string): Promise => ipcRenderer.invoke("search:set-root", root), - searchFiles: (query: string, options: unknown): Promise => - ipcRenderer.invoke("search:files", query, options), - }, - - browser: { - // View lifecycle (WebContentsView managed by main process) - createView: (initialUrl: string): Promise => - ipcRenderer.invoke("browser:create-view", initialUrl), - destroyView: (sessionId: string): void => ipcRenderer.send("browser:destroy-view", sessionId), - setBounds: (sessionId: string, bounds: NativeViewBounds): void => - ipcRenderer.send("browser:set-bounds", sessionId, bounds), - showView: (sessionId: string): void => ipcRenderer.send("browser:show-view", sessionId), - hideView: (sessionId: string): void => ipcRenderer.send("browser:hide-view", sessionId), - onStateUpdate: ( - callback: ( - event: unknown, - data: { - sessionId: string; - url?: string; - title?: string; - canGoBack?: boolean; - canGoForward?: boolean; - isLoading?: boolean; - }, - ) => void, - ): (() => void) => { - const handler = ( - _e: unknown, - data: { - sessionId: string; - url?: string; - title?: string; - canGoBack?: boolean; - canGoForward?: boolean; - isLoading?: boolean; - }, - ) => callback(_e, data); - ipcRenderer.on("browser:state-update", handler); - return () => ipcRenderer.removeListener("browser:state-update", handler); - }, - // Navigation - navigate: (sessionId: string, url: string): Promise => - ipcRenderer.invoke("browser:navigate", sessionId, url), - goBack: (sessionId: string): Promise => - ipcRenderer.invoke("browser:go-back", sessionId), - goForward: (sessionId: string): Promise => - ipcRenderer.invoke("browser:go-forward", sessionId), - reload: (sessionId: string): Promise => - ipcRenderer.invoke("browser:reload", sessionId), - stop: (sessionId: string): Promise => ipcRenderer.invoke("browser:stop", sessionId), - // Agent tools - readPage: (sessionId: string): Promise => - ipcRenderer.invoke("browser:read-page", sessionId), - screenshot: (sessionId: string): Promise => - ipcRenderer.invoke("browser:screenshot", sessionId), - click: (sessionId: string, index: number): Promise => - ipcRenderer.invoke("browser:click", sessionId, index), - type: (sessionId: string, text: string, index?: number): Promise => - ipcRenderer.invoke("browser:type", sessionId, text, index), - scroll: (sessionId: string, direction: string, amount: number): Promise => - ipcRenderer.invoke("browser:scroll", sessionId, direction, amount), - selectOption: ( - sessionId: string, - elementIndex: number, - optionIndex: number, - ): Promise => - ipcRenderer.invoke("browser:select-option", sessionId, elementIndex, optionIndex), - executeJs: (sessionId: string, code: string): Promise => - ipcRenderer.invoke("browser:execute-js", sessionId, code), - consoleLogs: (sessionId: string, since?: number): Promise => - ipcRenderer.invoke("browser:console-logs", sessionId, since), - listSessions: (): Promise => ipcRenderer.invoke("browser:list-sessions"), - }, - - claude: { - createSession: (): Promise => ipcRenderer.invoke("claude:create-session"), - destroySession: (sessionId: string): void => - ipcRenderer.send("claude:destroy-session", sessionId), - setBounds: (sessionId: string, bounds: NativeViewBounds): void => - ipcRenderer.send("claude:set-bounds", sessionId, bounds), - showView: (sessionId: string): void => ipcRenderer.send("claude:show-view", sessionId), - hideView: (sessionId: string): void => ipcRenderer.send("claude:hide-view", sessionId), - onHostOp: ( - callback: (request: { id: string; op: string; payload?: Record }) => void, - ): (() => void) => { - const handler = ( - _e: unknown, - request: { id: string; op: string; payload?: Record }, - ) => callback(request); - ipcRenderer.on("claude:host-op", handler); - return () => ipcRenderer.removeListener("claude:host-op", handler); - }, - sendHostOpResult: (result: { - id: string; - ok: boolean; - payload?: Record; - error?: string; - }): void => ipcRenderer.send("claude:host-op-result", result), - }, - - codex: { - createSession: (): Promise => ipcRenderer.invoke("codex:create-session"), - destroySession: (sessionId: string): void => - ipcRenderer.send("codex:destroy-session", sessionId), - setProjectRoot: (projectRoot: string | null): void => - ipcRenderer.send("codex:set-project-root", projectRoot), - setBounds: (sessionId: string, bounds: NativeViewBounds): void => - ipcRenderer.send("codex:set-bounds", sessionId, bounds), - showView: (sessionId: string): void => ipcRenderer.send("codex:show-view", sessionId), - hideView: (sessionId: string): void => ipcRenderer.send("codex:hide-view", sessionId), - }, - - about: { - getInfo: () => ipcRenderer.invoke("about:get-info"), - getIcon: () => ipcRenderer.invoke("about:get-icon"), - }, - - scripts: { - detect: (rootPath: string): Promise => - ipcRenderer.invoke("scripts:detect", rootPath), - start: (projectId: string, scriptName: string, cwd: string): Promise => - ipcRenderer.invoke("scripts:start", projectId, scriptName, cwd), - stop: (sessionId: string): Promise => ipcRenderer.invoke("scripts:stop", sessionId), - running: (): Promise => ipcRenderer.invoke("scripts:running"), - }, - - shell: { - openExternal: (url: string): void => ipcRenderer.send("shell:open-external", url), - openPath: (path: string): void => ipcRenderer.send("shell:open-path", path), - }, - - window: { - minimize: (): void => ipcRenderer.send("window:minimize"), - maximize: (): void => ipcRenderer.send("window:maximize"), - close: (): void => ipcRenderer.send("window:close"), - quit: (): void => ipcRenderer.send("window:quit"), - isMaximized: (): Promise => ipcRenderer.invoke("window:is-maximized"), - onMaximizeChange: (callback: (maximized: boolean) => void): (() => void) => { - const handler = (_e: unknown, maximized: boolean) => callback(maximized); - ipcRenderer.on("window:maximize-change", handler); - return () => ipcRenderer.removeListener("window:maximize-change", handler); - }, - zoomIn: (): number => { - const l = webFrame.getZoomLevel() + 0.5; - webFrame.setZoomLevel(l); - return l; - }, - zoomOut: (): number => { - const l = webFrame.getZoomLevel() - 0.5; - webFrame.setZoomLevel(l); - return l; - }, - zoomReset: (): number => { - webFrame.setZoomLevel(0); - return 0; - }, - getZoomLevel: (): number => webFrame.getZoomLevel(), - setZoomLevel: (level: number): void => { - webFrame.setZoomLevel(level); - }, - spanAllMonitors: (): void => ipcRenderer.send("window:span-all-monitors"), - restoreSpan: (): void => ipcRenderer.send("window:restore-span"), - isSpanned: (): Promise => ipcRenderer.invoke("window:is-spanned"), - getDisplayCount: (): Promise => ipcRenderer.invoke("window:display-count"), - onSpanChange: (callback: (spanned: boolean) => void): (() => void) => { - const handler = (_e: unknown, spanned: boolean) => callback(spanned); - ipcRenderer.on("window:span-change", handler); - return () => ipcRenderer.removeListener("window:span-change", handler); - }, - }, - - settings: { - load: (): Promise => ipcRenderer.invoke("settings:load"), - save: (data: string): Promise => ipcRenderer.invoke("settings:save", data), - }, - - integrations: { - listStatus: (): Promise => ipcRenderer.invoke("integrations:list-status"), - checkUpdates: (integrationId?: IntegrationId): Promise => - ipcRenderer.invoke("integrations:check-updates", integrationId), - install: ( - integrationId: IntegrationId, - options?: { reinstall?: boolean }, - ): Promise => - ipcRenderer.invoke("integrations:install", integrationId, options), - update: (integrationId: IntegrationId): Promise => - ipcRenderer.invoke("integrations:update", integrationId), - verify: (integrationId: IntegrationId): Promise => - ipcRenderer.invoke("integrations:verify", integrationId), - revealPath: (integrationId: IntegrationId): Promise => - ipcRenderer.invoke("integrations:reveal-path", integrationId), - revealLog: (integrationId: IntegrationId): Promise => - ipcRenderer.invoke("integrations:reveal-log", integrationId), - onProgress: (callback: (progress: IntegrationProgress) => void): (() => void) => { - const handler = (_e: unknown, progress: IntegrationProgress) => callback(progress); - ipcRenderer.on("integrations:progress", handler); - return () => ipcRenderer.removeListener("integrations:progress", handler); - }, - }, - - github: { - checkCli: (): Promise => ipcRenderer.invoke("github:check-cli"), - installCli: (): Promise => ipcRenderer.invoke("github:install-cli"), - setCwd: (cwd: string): Promise => ipcRenderer.invoke("github:set-cwd", cwd), - repoInfo: (): Promise => ipcRenderer.invoke("github:repo-info"), - prList: (state?: string): Promise => ipcRenderer.invoke("github:pr-list", state), - prGet: (number: number): Promise => ipcRenderer.invoke("github:pr-get", number), - prDiff: (number: number): Promise => ipcRenderer.invoke("github:pr-diff", number), - prCreate: ( - title: string, - body: string, - base: string, - head: string, - reviewers?: string[], - labels?: string[], - ): Promise => - ipcRenderer.invoke("github:pr-create", title, body, base, head, reviewers, labels), - prMerge: (number: number, method: string, deleteBranch?: boolean): Promise => - ipcRenderer.invoke("github:pr-merge", number, method, deleteBranch), - prClose: (number: number): Promise => ipcRenderer.invoke("github:pr-close", number), - prReviews: (number: number): Promise => - ipcRenderer.invoke("github:pr-reviews", number), - prReviewComments: (number: number): Promise => - ipcRenderer.invoke("github:pr-review-comments", number), - prReviewSubmit: ( - number: number, - event: string, - body: string, - comments?: unknown[], - ): Promise => - ipcRenderer.invoke("github:pr-review-submit", number, event, body, comments), - issueList: (state?: string): Promise => - ipcRenderer.invoke("github:issue-list", state), - issueGet: (number: number): Promise => ipcRenderer.invoke("github:issue-get", number), - issueCreate: ( - title: string, - body: string, - labels?: string[], - assignees?: string[], - ): Promise => - ipcRenderer.invoke("github:issue-create", title, body, labels, assignees), - issueComment: (number: number, body: string): Promise => - ipcRenderer.invoke("github:issue-comment", number, body), - issueClose: (number: number): Promise => - ipcRenderer.invoke("github:issue-close", number), - issueReopen: (number: number): Promise => - ipcRenderer.invoke("github:issue-reopen", number), - issueComments: (number: number): Promise => - ipcRenderer.invoke("github:issue-comments", number), - labelsList: (): Promise => ipcRenderer.invoke("github:labels-list"), - collaboratorsList: (): Promise => ipcRenderer.invoke("github:collaborators-list"), - prTemplate: (): Promise => ipcRenderer.invoke("github:pr-template"), - }, - - project: { - list: (): Promise => ipcRenderer.invoke("project:list"), - add: (rootPath: string, name?: string): Promise => - ipcRenderer.invoke("project:add", rootPath, name), - remove: (id: string): Promise => ipcRenderer.invoke("project:remove", id), - update: (id: string, updates: Record): Promise => - ipcRenderer.invoke("project:update", id, updates), - get: (id: string): Promise => ipcRenderer.invoke("project:get", id), - detectScripts: (rootPath: string): Promise => - ipcRenderer.invoke("project:detect-scripts", rootPath), - }, - - workspaces: { - list: (projectId: string): Promise => - ipcRenderer.invoke("workspaces:list", projectId), - load: (projectId: string, workspaceId: string): Promise => - ipcRenderer.invoke("workspaces:load", projectId, workspaceId), - save: (projectId: string, workspace: string): Promise => - ipcRenderer.invoke("workspaces:save", projectId, workspace), - create: ( - projectId: string, - name: string, - type?: string, - branch?: string, - worktreePath?: string, - ): Promise => - ipcRenderer.invoke("workspaces:create", projectId, name, type, branch, worktreePath), - delete: (projectId: string, workspaceId: string): Promise => - ipcRenderer.invoke("workspaces:delete", projectId, workspaceId), - rename: (projectId: string, workspaceId: string, name: string): Promise => - ipcRenderer.invoke("workspaces:rename", projectId, workspaceId, name), - }, - - workspace: { - load: (): Promise => ipcRenderer.invoke("workspace:load"), - save: (data: string): Promise => ipcRenderer.invoke("workspace:save", data), - loadState: (projectRoot: string): Promise => - ipcRenderer.invoke("workspace:load-state", projectRoot), - saveState: (projectRoot: string, state: string): Promise => - ipcRenderer.invoke("workspace:save-state", projectRoot, state), - loadSettings: (projectRoot: string): Promise => - ipcRenderer.invoke("workspace:load-settings", projectRoot), - saveSettings: (projectRoot: string, data: string): Promise => - ipcRenderer.invoke("workspace:save-settings", projectRoot, data), - }, - - dialog: { - openFolder: (): Promise => ipcRenderer.invoke("dialog:open-folder"), - openFile: (filters?: Array<{ name?: string; extensions?: string[] }>): Promise => - ipcRenderer.invoke("dialog:open-file", filters), - saveFile: (defaultName?: string): Promise => - ipcRenderer.invoke("dialog:save-file", defaultName), - showMessageBox: (options: { - type?: string; - title?: string; - message: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - cancelId?: number; - }): Promise => ipcRenderer.invoke("dialog:show-message-box", options), - }, - - onOpenFile: (callback: (filePath: string) => void): (() => void) => { - const handler = (_e: unknown, filePath: string) => callback(filePath); - ipcRenderer.on("file:open", handler); - return () => ipcRenderer.removeListener("file:open", handler); - }, - - onIpcHealthWarning: (callback: (missingChannels: string[]) => void): (() => void) => { - const handler = (_e: unknown, channels: string[]) => callback(channels); - ipcRenderer.on("ipc:health-warning", handler); - return () => ipcRenderer.removeListener("ipc:health-warning", handler); - }, -}; - -contextBridge.exposeInMainWorld("api", api); - -export type ApiType = typeof api; diff --git a/apps/desktop/src/src/liteeditor/registerLiteEditorDesktop.ts b/apps/desktop/src/src/liteeditor/registerLiteEditorDesktop.ts deleted file mode 100644 index 80b2222..0000000 --- a/apps/desktop/src/src/liteeditor/registerLiteEditorDesktop.ts +++ /dev/null @@ -1,391 +0,0 @@ -// @ts-nocheck -import * as FS from "node:fs"; -import * as Path from "node:path"; - -import { app, BrowserWindow, dialog, ipcMain, nativeImage, screen, shell } from "electron"; -import type { Rectangle } from "electron"; -import { registerBrowserHandlers, shutdownBrowserHandlers } from "./ipc/browser-handlers"; -import { registerClaudeHandlers, shutdownClaudeHandlers } from "./ipc/claude-handlers"; -import { registerCodexHandlers, shutdownCodexHandlers } from "./ipc/codex-handlers"; -import { registerFsHandlers, shutdownFsHandlers } from "./ipc/fs-handlers"; -import { registerGitHandlers } from "./ipc/git-handlers"; -import { registerGitHubHandlers } from "./ipc/github-handlers"; -import { - registerIntegrationsHandlers, - shutdownIntegrationsHandlers, -} from "./ipc/integrations-handlers"; -import { registerProjectHandlers } from "./ipc/project-handlers"; -import { registerPtyHandlers, shutdownPtyHandlers } from "./ipc/pty-handlers"; -import { registerScriptHandlers, shutdownScriptHandlers } from "./ipc/script-handlers"; -import { registerSearchHandlers } from "./ipc/search-handlers"; -import { registerWorkspaceCrudHandlers } from "./ipc/workspace-crud-handlers"; -import { registerWorkspaceHandlers } from "./ipc/workspace-handlers"; - -interface LiteEditorDesktopRegistrationOptions { - appDescription: string; - appDisplayName: string; - commitHash: string | null; - iconPath: string | null; -} - -interface WindowSpanState { - restoreBounds: Rectangle | null; - spanned: boolean; -} - -const DIALOG_OPEN_FOLDER_CHANNEL = "dialog:open-folder"; -const DIALOG_OPEN_FILE_CHANNEL = "dialog:open-file"; -const DIALOG_SAVE_FILE_CHANNEL = "dialog:save-file"; -const DIALOG_SHOW_MESSAGE_BOX_CHANNEL = "dialog:show-message-box"; -const SHELL_OPEN_EXTERNAL_CHANNEL = "shell:open-external"; -const SHELL_OPEN_PATH_CHANNEL = "shell:open-path"; -const WINDOW_MINIMIZE_CHANNEL = "window:minimize"; -const WINDOW_MAXIMIZE_CHANNEL = "window:maximize"; -const WINDOW_CLOSE_CHANNEL = "window:close"; -const WINDOW_QUIT_CHANNEL = "window:quit"; -const WINDOW_IS_MAXIMIZED_CHANNEL = "window:is-maximized"; -const WINDOW_MAXIMIZE_CHANGE_CHANNEL = "window:maximize-change"; -const WINDOW_SPAN_ALL_MONITORS_CHANNEL = "window:span-all-monitors"; -const WINDOW_RESTORE_SPAN_CHANNEL = "window:restore-span"; -const WINDOW_IS_SPANNED_CHANNEL = "window:is-spanned"; -const WINDOW_DISPLAY_COUNT_CHANNEL = "window:display-count"; -const WINDOW_SPAN_CHANGE_CHANNEL = "window:span-change"; -const ABOUT_GET_INFO_CHANNEL = "about:get-info"; -const ABOUT_GET_ICON_CHANNEL = "about:get-icon"; - -let globalHandlersRegistered = false; -let bridgeHandlersRegistered = false; -let activeMainWindow: BrowserWindow | null = null; -const spanStateByWindow = new WeakMap(); -const observedWindows = new WeakSet(); - -function getWindowSpanState(window: BrowserWindow): WindowSpanState { - const existing = spanStateByWindow.get(window); - if (existing) { - return existing; - } - - const state: WindowSpanState = { - restoreBounds: null, - spanned: false, - }; - spanStateByWindow.set(window, state); - return state; -} - -function getOwnerWindow(): BrowserWindow | null { - const focused = BrowserWindow.getFocusedWindow(); - if (focused && !focused.isDestroyed()) { - return focused; - } - - if (activeMainWindow && !activeMainWindow.isDestroyed()) { - return activeMainWindow; - } - - return BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null; -} - -function attachWindowObservers(window: BrowserWindow): void { - if (observedWindows.has(window)) { - return; - } - observedWindows.add(window); - - window.on("maximize", () => { - window.webContents.send(WINDOW_MAXIMIZE_CHANGE_CHANNEL, true); - }); - window.on("unmaximize", () => { - window.webContents.send(WINDOW_MAXIMIZE_CHANGE_CHANNEL, false); - }); - window.on("closed", () => { - const state = spanStateByWindow.get(window); - if (state) { - state.restoreBounds = null; - state.spanned = false; - } - if (activeMainWindow === window) { - activeMainWindow = null; - } - }); -} - -function registerBridgeHandlers(options: LiteEditorDesktopRegistrationOptions): void { - if (bridgeHandlersRegistered) { - return; - } - bridgeHandlersRegistered = true; - - ipcMain.removeHandler(DIALOG_OPEN_FOLDER_CHANNEL); - ipcMain.handle(DIALOG_OPEN_FOLDER_CHANNEL, async () => { - const owner = getOwnerWindow(); - const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - return result.canceled ? null : (result.filePaths[0] ?? null); - }); - - ipcMain.removeHandler(DIALOG_OPEN_FILE_CHANNEL); - ipcMain.handle( - DIALOG_OPEN_FILE_CHANNEL, - async ( - _event, - filters?: Array<{ - name?: string; - extensions?: string[]; - }>, - ) => { - const owner = getOwnerWindow(); - const normalizedFilters = Array.isArray(filters) - ? filters - .filter((filter) => Array.isArray(filter?.extensions)) - .map((filter) => ({ - name: - typeof filter?.name === "string" && filter.name.length > 0 ? filter.name : "Files", - extensions: (filter?.extensions ?? []).filter((value) => typeof value === "string"), - })) - .filter((filter) => filter.extensions.length > 0) - : undefined; - const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openFile"], - ...(normalizedFilters ? { filters: normalizedFilters } : {}), - }) - : await dialog.showOpenDialog({ - properties: ["openFile"], - ...(normalizedFilters ? { filters: normalizedFilters } : {}), - }); - return result.canceled ? null : (result.filePaths[0] ?? null); - }, - ); - - ipcMain.removeHandler(DIALOG_SAVE_FILE_CHANNEL); - ipcMain.handle(DIALOG_SAVE_FILE_CHANNEL, async (_event, defaultName?: string) => { - const owner = getOwnerWindow(); - const result = owner - ? await dialog.showSaveDialog(owner, { - ...(typeof defaultName === "string" && defaultName.length > 0 - ? { defaultPath: defaultName } - : {}), - }) - : await dialog.showSaveDialog({ - ...(typeof defaultName === "string" && defaultName.length > 0 - ? { defaultPath: defaultName } - : {}), - }); - return result.canceled ? null : (result.filePath ?? null); - }); - - ipcMain.removeHandler(DIALOG_SHOW_MESSAGE_BOX_CHANNEL); - ipcMain.handle( - DIALOG_SHOW_MESSAGE_BOX_CHANNEL, - async ( - _event, - rawOptions: { - type?: string; - title?: string; - message: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - cancelId?: number; - }, - ) => { - const owner = getOwnerWindow(); - const type = - rawOptions?.type === "none" || - rawOptions?.type === "info" || - rawOptions?.type === "error" || - rawOptions?.type === "question" || - rawOptions?.type === "warning" - ? rawOptions.type - : "info"; - const optionsForDialog = { - type, - title: rawOptions?.title ?? options.appDisplayName, - message: rawOptions?.message ?? "", - detail: rawOptions?.detail, - buttons: - Array.isArray(rawOptions?.buttons) && rawOptions.buttons.length > 0 - ? rawOptions.buttons - : ["OK"], - defaultId: rawOptions?.defaultId, - cancelId: rawOptions?.cancelId, - } as const; - const result = owner - ? await dialog.showMessageBox(owner, optionsForDialog) - : await dialog.showMessageBox(optionsForDialog); - return result.response; - }, - ); - - ipcMain.on(SHELL_OPEN_EXTERNAL_CHANNEL, (_event, rawUrl: unknown) => { - if (typeof rawUrl === "string" && rawUrl.length > 0) { - void shell.openExternal(rawUrl); - } - }); - - ipcMain.on(SHELL_OPEN_PATH_CHANNEL, (_event, rawPath: unknown) => { - if (typeof rawPath === "string" && rawPath.length > 0) { - void shell.openPath(rawPath); - } - }); - - ipcMain.on(WINDOW_MINIMIZE_CHANNEL, () => { - getOwnerWindow()?.minimize(); - }); - - ipcMain.on(WINDOW_MAXIMIZE_CHANNEL, () => { - const owner = getOwnerWindow(); - if (!owner) { - return; - } - if (owner.isMaximized()) { - owner.unmaximize(); - return; - } - owner.maximize(); - }); - - ipcMain.on(WINDOW_CLOSE_CHANNEL, () => { - getOwnerWindow()?.close(); - }); - - ipcMain.on(WINDOW_QUIT_CHANNEL, () => { - app.quit(); - }); - - ipcMain.removeHandler(WINDOW_IS_MAXIMIZED_CHANNEL); - ipcMain.handle(WINDOW_IS_MAXIMIZED_CHANNEL, async () => { - return getOwnerWindow()?.isMaximized() ?? false; - }); - - ipcMain.on(WINDOW_SPAN_ALL_MONITORS_CHANNEL, () => { - const owner = getOwnerWindow(); - if (!owner) { - return; - } - const state = getWindowSpanState(owner); - if (!state.spanned) { - state.restoreBounds = owner.getBounds(); - } - const displays = screen.getAllDisplays(); - if (displays.length === 0) { - return; - } - const union = displays.reduce( - (accumulator, display) => ({ - x: Math.min(accumulator.x, display.bounds.x), - y: Math.min(accumulator.y, display.bounds.y), - width: - Math.max(accumulator.x + accumulator.width, display.bounds.x + display.bounds.width) - - Math.min(accumulator.x, display.bounds.x), - height: - Math.max(accumulator.y + accumulator.height, display.bounds.y + display.bounds.height) - - Math.min(accumulator.y, display.bounds.y), - }), - { ...displays[0]!.bounds }, - ); - state.spanned = true; - owner.setBounds(union); - owner.webContents.send(WINDOW_SPAN_CHANGE_CHANNEL, true); - }); - - ipcMain.on(WINDOW_RESTORE_SPAN_CHANNEL, () => { - const owner = getOwnerWindow(); - if (!owner) { - return; - } - const state = getWindowSpanState(owner); - if (state.restoreBounds) { - owner.setBounds(state.restoreBounds); - } - state.restoreBounds = null; - state.spanned = false; - owner.webContents.send(WINDOW_SPAN_CHANGE_CHANNEL, false); - }); - - ipcMain.removeHandler(WINDOW_IS_SPANNED_CHANNEL); - ipcMain.handle(WINDOW_IS_SPANNED_CHANNEL, async () => { - const owner = getOwnerWindow(); - return owner ? getWindowSpanState(owner).spanned : false; - }); - - ipcMain.removeHandler(WINDOW_DISPLAY_COUNT_CHANNEL); - ipcMain.handle(WINDOW_DISPLAY_COUNT_CHANNEL, async () => { - return screen.getAllDisplays().length; - }); - - ipcMain.removeHandler(ABOUT_GET_INFO_CHANNEL); - ipcMain.handle(ABOUT_GET_INFO_CHANNEL, async () => { - return { - appName: options.appDisplayName, - appDescription: options.appDescription, - version: app.getVersion(), - commitHash: options.commitHash ?? undefined, - buildDate: process.env.BUILD_DATE ?? undefined, - electronVersion: process.versions.electron, - nodeVersion: process.versions.node, - platform: process.platform, - links: [ - { - label: "LiteEditor", - url: "https://github.com/t3tools/t3code", - }, - ], - }; - }); - - ipcMain.removeHandler(ABOUT_GET_ICON_CHANNEL); - ipcMain.handle(ABOUT_GET_ICON_CHANNEL, async () => { - if (!options.iconPath || !FS.existsSync(options.iconPath)) { - return null; - } - const image = nativeImage.createFromPath(options.iconPath); - if (image.isEmpty()) { - return null; - } - return `data:image/png;base64,${image.toPNG().toString("base64")}`; - }); -} - -export function registerLiteEditorDesktop( - mainWindow: BrowserWindow, - options: LiteEditorDesktopRegistrationOptions, -): void { - activeMainWindow = mainWindow; - attachWindowObservers(mainWindow); - registerBridgeHandlers(options); - - if (!globalHandlersRegistered) { - registerFsHandlers(); - registerPtyHandlers(); - registerSearchHandlers(); - registerGitHandlers(); - registerProjectHandlers(); - registerWorkspaceHandlers(); - registerWorkspaceCrudHandlers(); - registerGitHubHandlers(); - registerScriptHandlers(); - globalHandlersRegistered = true; - } - - registerBrowserHandlers(mainWindow); - registerClaudeHandlers(mainWindow); - registerCodexHandlers(mainWindow); - registerIntegrationsHandlers(mainWindow); -} - -export function shutdownLiteEditorDesktop(): void { - shutdownIntegrationsHandlers(); - shutdownClaudeHandlers(); - shutdownCodexHandlers(); - shutdownBrowserHandlers(); - shutdownScriptHandlers(); - shutdownPtyHandlers(); - shutdownFsHandlers(); -} diff --git a/apps/desktop/src/src/liteeditor/services/agent-bridge.ts b/apps/desktop/src/src/liteeditor/services/agent-bridge.ts deleted file mode 100644 index 7a14e31..0000000 --- a/apps/desktop/src/src/liteeditor/services/agent-bridge.ts +++ /dev/null @@ -1,427 +0,0 @@ -// @ts-nocheck -import * as http from "http"; -import * as crypto from "crypto"; -import * as fs from "fs"; -import * as path from "path"; -import { app } from "electron"; -import type { BrowserWindow } from "electron"; -import type { PtyManager } from "./pty-manager"; -import type { BrowserManager } from "./browser-manager"; -import { - DOM_INDEX_SCRIPT, - getClickScript, - getTypeScript, - getScrollScript, - getSelectOptionScript, -} from "./dom-helper"; - -const PORT = 7423; -const HOST = "127.0.0.1"; - -export class AgentBridge { - private server: http.Server | null = null; - private ptyManager: PtyManager; - private browserManager: BrowserManager; - private getMainWindow: () => BrowserWindow | null; - readonly token: string; - - constructor( - ptyManager: PtyManager, - browserManager: BrowserManager, - getMainWindow: () => BrowserWindow | null, - ) { - this.ptyManager = ptyManager; - this.browserManager = browserManager; - this.getMainWindow = getMainWindow; - this.token = crypto.randomBytes(32).toString("hex"); - } - - private async focusTerminal(sessionId: string): Promise { - const win = this.getMainWindow(); - if (win) { - const safe = sessionId.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); - await win.webContents - .executeJavaScript(`window.__focusPtySession && window.__focusPtySession('${safe}')`) - .catch(() => {}); - } - } - - start(): Promise { - return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - this.server.on("error", reject); - this.server.listen(PORT, HOST, () => { - console.log(`Agent Bridge listening on ${HOST}:${PORT}`); - this.persistToken(); - resolve(); - }); - }); - } - - stop(): Promise { - this.removeTokenFile(); - return new Promise((resolve) => { - if (this.server) { - this.server.close(() => resolve()); - } else { - resolve(); - } - }); - } - - private get tokenFilePath(): string { - const dir = path.join(app.getPath("home"), ".liteeditor"); - return path.join(dir, "bridge-token"); - } - - private persistToken(): void { - try { - const filePath = this.tokenFilePath; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, this.token, { mode: 0o600 }); - } catch (err) { - console.error("Failed to persist bridge token:", err); - } - } - - private removeTokenFile(): void { - try { - fs.unlinkSync(this.tokenFilePath); - } catch { - /* file may not exist */ - } - } - - private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { - // Bearer token auth — reject requests without valid token - const authHeader = req.headers["authorization"] || ""; - const expectedHeader = `Bearer ${this.token}`; - if (authHeader !== expectedHeader) { - this.json(res, 401, { error: "Unauthorized" }); - return; - } - - const url = req.url || ""; - const method = req.method || ""; - - // --- PTY endpoints --- - - if (method === "GET" && url === "/pty/list") { - const sessions = this.ptyManager.listSessions(); - this.json(res, 200, { sessions }); - return; - } - - if (method === "POST" && (url === "/pty/read" || url === "/pty/write" || url === "/pty/talk")) { - this.readBody(req, (err, body) => { - if (err) { - this.json(res, 400, { error: "Invalid request body" }); - return; - } - - let parsed: Record; - try { - parsed = JSON.parse(body); - } catch { - this.json(res, 400, { error: "Invalid JSON" }); - return; - } - - if (url === "/pty/read") { - this.handlePtyRead(res, parsed); - } else if (url === "/pty/talk") { - this.handlePtyTalk(res, parsed); - } else { - this.handlePtyWrite(res, parsed); - } - }); - return; - } - - // --- Browser endpoints --- - - if (method === "GET" && url === "/browser/list") { - const sessions = this.browserManager.listSessions(); - this.json(res, 200, { sessions }); - return; - } - - if (method === "POST" && url.startsWith("/browser/")) { - this.readBody(req, (err, body) => { - if (err) { - this.json(res, 400, { error: "Invalid request body" }); - return; - } - - let parsed: Record; - try { - parsed = JSON.parse(body); - } catch { - this.json(res, 400, { error: "Invalid JSON" }); - return; - } - - this.handleBrowserPost(url, res, parsed); - }); - return; - } - - this.json(res, 404, { error: "Not found" }); - } - - // --- PTY handlers --- - - private handlePtyRead(res: http.ServerResponse, body: Record): void { - const sessionId = body.session_id as string | undefined; - if (!sessionId) { - this.json(res, 400, { error: "Missing session_id" }); - return; - } - - const output = this.ptyManager.readOutput(sessionId); - if (output === null) { - this.json(res, 404, { error: `Session '${sessionId}' not found` }); - return; - } - - this.json(res, 200, { session_id: sessionId, output }); - } - - private handlePtyWrite(res: http.ServerResponse, body: Record): void { - const sessionId = body.session_id as string | undefined; - const data = body.data as string | undefined; - if (!sessionId) { - this.json(res, 400, { error: "Missing session_id" }); - return; - } - if (data === undefined || data === null) { - this.json(res, 400, { error: "Missing data" }); - return; - } - - const output = this.ptyManager.readOutput(sessionId); - if (output === null) { - this.json(res, 404, { error: `Session '${sessionId}' not found` }); - return; - } - - this.focusTerminal(sessionId).then(() => { - setTimeout(() => { - const success = this.ptyManager.write(sessionId, String(data)); - if (!success) { - this.json(res, 500, { ok: false, error: `Write failed for session '${sessionId}'` }); - return; - } - this.json(res, 200, { ok: true, bytes: String(data).length }); - }, 200); - }); - } - - private handlePtyTalk(res: http.ServerResponse, body: Record): void { - const sessionId = body.session_id as string | undefined; - const command = body.command as string | undefined; - if (!sessionId) { - this.json(res, 400, { error: "Missing session_id" }); - return; - } - if (command === undefined || command === null) { - this.json(res, 400, { error: "Missing command" }); - return; - } - - const output = this.ptyManager.readOutput(sessionId); - if (output === null) { - this.json(res, 404, { error: `Session '${sessionId}' not found` }); - return; - } - - this.focusTerminal(sessionId).then(() => { - setTimeout(() => { - const success = this.ptyManager.write(sessionId, command + "\r"); - if (!success) { - this.json(res, 500, { ok: false, error: `Talk failed for session '${sessionId}'` }); - return; - } - this.json(res, 200, { ok: true, bytes: command.length + 1 }); - }, 200); - }); - } - - // --- Browser handlers --- - - private async handleBrowserPost( - url: string, - res: http.ServerResponse, - body: Record, - ): Promise { - const route = url.replace("/browser/", ""); - const sessionId = body.session_id as string | undefined; - - // Routes that don't require session_id - if (route === "list") { - const sessions = this.browserManager.listSessions(); - this.json(res, 200, { sessions }); - return; - } - - if (!sessionId) { - this.json(res, 400, { error: "Missing session_id" }); - return; - } - - const wc = this.browserManager.getWebContents(sessionId); - if (!wc) { - this.json(res, 404, { error: `Browser session '${sessionId}' not found` }); - return; - } - - try { - switch (route) { - case "navigate": { - let targetUrl = body.url as string; - if (!targetUrl) { - this.json(res, 400, { error: "Missing url" }); - return; - } - if (!/^https?:\/\//i.test(targetUrl) && !targetUrl.startsWith("file://")) { - targetUrl = "https://" + targetUrl; - } - await wc.loadURL(targetUrl); - this.json(res, 200, { success: true, url: wc.getURL() }); - break; - } - - case "go-back": { - if (!wc.canGoBack()) { - this.json(res, 200, { success: false, error: "Cannot go back" }); - return; - } - wc.goBack(); - this.json(res, 200, { success: true }); - break; - } - - case "go-forward": { - if (!wc.canGoForward()) { - this.json(res, 200, { success: false, error: "Cannot go forward" }); - return; - } - wc.goForward(); - this.json(res, 200, { success: true }); - break; - } - - case "reload": { - wc.reload(); - this.json(res, 200, { success: true }); - break; - } - - case "read-page": { - const result = await wc.executeJavaScript(DOM_INDEX_SCRIPT); - this.json(res, 200, { success: true, ...result }); - break; - } - - case "screenshot": { - const image = await wc.capturePage(); - const dataUrl = "data:image/png;base64," + image.toPNG().toString("base64"); - this.json(res, 200, { success: true, dataUrl }); - break; - } - - case "click": { - const index = body.index as number; - if (index === undefined || index === null) { - this.json(res, 400, { error: "Missing index" }); - return; - } - const result = await wc.executeJavaScript(getClickScript(index)); - this.json(res, 200, result); - break; - } - - case "type": { - const text = body.text as string; - if (text === undefined || text === null) { - this.json(res, 400, { error: "Missing text" }); - return; - } - const idx = body.index as number | undefined; - const result = await wc.executeJavaScript(getTypeScript(text, idx)); - this.json(res, 200, result); - break; - } - - case "scroll": { - const direction = body.direction as "up" | "down" | "left" | "right"; - const amount = (body.amount as number) || 300; - if (!direction) { - this.json(res, 400, { error: "Missing direction" }); - return; - } - const result = await wc.executeJavaScript(getScrollScript(direction, amount)); - this.json(res, 200, result); - break; - } - - case "select-option": { - const elementIndex = body.element_index as number; - const optionIndex = body.option_index as number; - if (elementIndex === undefined || optionIndex === undefined) { - this.json(res, 400, { error: "Missing element_index or option_index" }); - return; - } - const result = await wc.executeJavaScript( - getSelectOptionScript(elementIndex, optionIndex), - ); - this.json(res, 200, result); - break; - } - - case "execute-js": { - const code = body.code as string; - if (!code) { - this.json(res, 400, { error: "Missing code" }); - return; - } - const result = await wc.executeJavaScript(code); - this.json(res, 200, { success: true, result }); - break; - } - - case "console-logs": { - const since = body.since as number | undefined; - const logs = this.browserManager.getConsoleLogs(sessionId, since); - this.json(res, 200, { success: true, logs }); - break; - } - - default: - this.json(res, 404, { error: `Unknown browser route: ${route}` }); - } - } catch (err) { - this.json(res, 500, { success: false, error: String(err) }); - } - } - - // --- Helpers --- - - private json(res: http.ServerResponse, status: number, data: unknown): void { - const payload = JSON.stringify(data); - res.writeHead(status, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - }); - res.end(payload); - } - - private readBody(req: http.IncomingMessage, cb: (err: Error | null, body: string) => void): void { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => cb(null, Buffer.concat(chunks).toString("utf-8"))); - req.on("error", (err) => cb(err, "")); - } -} diff --git a/apps/desktop/src/src/liteeditor/services/browser-manager.ts b/apps/desktop/src/src/liteeditor/services/browser-manager.ts deleted file mode 100644 index 58880a5..0000000 --- a/apps/desktop/src/src/liteeditor/services/browser-manager.ts +++ /dev/null @@ -1,206 +0,0 @@ -// @ts-nocheck -import { WebContentsView, BrowserWindow } from "electron"; -import { type NativeViewBounds, toContentBounds } from "./native-view-bounds"; - -let counter = 0; - -interface ConsoleLogEntry { - level: string; - message: string; - timestamp: number; -} - -interface BrowserSession { - id: string; - view: WebContentsView; - mainWindow: BrowserWindow; - hidden: boolean; - consoleLogs: ConsoleLogEntry[]; -} - -const MAX_CONSOLE_LOGS = 500; - -export class BrowserManager { - private sessions = new Map(); - - createView(mainWindow: BrowserWindow, initialUrl: string): string { - const id = `browser-${++counter}-${Date.now()}`; - - const view = new WebContentsView({ - webPreferences: { - partition: "persist:browser", - sandbox: true, - contextIsolation: true, - }, - }); - - // Chrome-like user-agent so Google services work properly - const defaultUA = view.webContents.getUserAgent(); - view.webContents.setUserAgent( - defaultUA.replace(/\s*Electron\/\S+/, "").replace(/\s*LiteEditor\/\S+/, ""), - ); - - // Start at zero bounds until the renderer reports real bounds - view.setBounds({ x: 0, y: 0, width: 0, height: 0 }); - mainWindow.contentView.addChildView(view); - - const session: BrowserSession = { - id, - view, - mainWindow, - hidden: false, - consoleLogs: [], - }; - this.sessions.set(id, session); - - const wc = view.webContents; - - wc.on("console-message", (_e, level, message) => { - this.addConsoleLog(id, level, message); - }); - - wc.on("did-navigate", () => { - this.sendStateUpdate(id); - }); - - wc.on("did-navigate-in-page", () => { - this.sendStateUpdate(id); - }); - - wc.on("did-start-loading", () => { - mainWindow.webContents.send("browser:state-update", { - sessionId: id, - isLoading: true, - }); - }); - - wc.on("did-stop-loading", () => { - this.sendStateUpdate(id); - }); - - wc.on("page-title-updated", (_e, title) => { - mainWindow.webContents.send("browser:state-update", { - sessionId: id, - title, - }); - }); - - wc.loadURL(initialUrl); - - return id; - } - - private sendStateUpdate(sessionId: string) { - const session = this.sessions.get(sessionId); - if (!session) return; - const wc = session.view.webContents; - session.mainWindow.webContents.send("browser:state-update", { - sessionId, - url: wc.getURL(), - title: wc.getTitle(), - canGoBack: wc.canGoBack(), - canGoForward: wc.canGoForward(), - isLoading: wc.isLoading(), - }); - } - - destroyView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session) return; - this.sessions.delete(sessionId); - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may already be closed */ - } - try { - session.view.webContents.close(); - } catch { - /* already destroyed */ - } - } - - setBounds(sessionId: string, bounds: NativeViewBounds): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.view.setBounds(toContentBounds(session.mainWindow, bounds)); - } - - showView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || !session.hidden) return; - session.hidden = false; - try { - session.mainWindow.contentView.addChildView(session.view); - } catch { - /* window may be closed */ - } - } - - hideView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.hidden = true; - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may be closed */ - } - } - - getWebContents(sessionId: string) { - const session = this.sessions.get(sessionId); - if (!session) return undefined; - return session.view.webContents; - } - - getSession(sessionId: string) { - return this.sessions.get(sessionId); - } - - listSessions(): string[] { - return Array.from(this.sessions.keys()); - } - - private addConsoleLog(sessionId: string, level: number, message: string) { - const session = this.sessions.get(sessionId); - if (!session) return; - - const levelMap: Record = { - 0: "verbose", - 1: "info", - 2: "warning", - 3: "error", - }; - - session.consoleLogs.push({ - level: levelMap[level] || "info", - message, - timestamp: Date.now(), - }); - - // Circular buffer — trim oldest entries - if (session.consoleLogs.length > MAX_CONSOLE_LOGS) { - session.consoleLogs = session.consoleLogs.slice(-MAX_CONSOLE_LOGS); - } - } - - getConsoleLogs(sessionId: string, since?: number): ConsoleLogEntry[] { - const session = this.sessions.get(sessionId); - if (!session) return []; - if (since) { - return session.consoleLogs.filter((log) => log.timestamp >= since); - } - return [...session.consoleLogs]; - } - - remove(sessionId: string): void { - this.destroyView(sessionId); - } - - removeAll(): void { - for (const sessionId of Array.from(this.sessions.keys())) { - this.destroyView(sessionId); - } - } -} diff --git a/apps/desktop/src/src/liteeditor/services/claude-bridge.ts b/apps/desktop/src/src/liteeditor/services/claude-bridge.ts deleted file mode 100644 index b05729b..0000000 --- a/apps/desktop/src/src/liteeditor/services/claude-bridge.ts +++ /dev/null @@ -1,1460 +0,0 @@ -// @ts-nocheck -import { spawn, ChildProcess, execSync } from "child_process"; -import { join } from "path"; -import { existsSync, readdirSync, readFileSync, statSync } from "fs"; -import { createReadStream } from "fs"; -import { createInterface } from "readline"; -import { WebContents, dialog, shell } from "electron"; -import { ClaudeManager } from "./claude-manager"; -import type { PtyManager } from "./pty-manager"; - -interface ChannelProcess { - proc: ChildProcess; - sessionId: string; -} - -interface ClaudeCredentials { - claudeAiOauth?: { - accessToken?: string; - subscriptionType?: string; - rateLimitTier?: string; - }; - organizationUuid?: string; -} - -type RendererHostOpResult = Record; -type RendererHostOpInvoker = ( - op: string, - payload?: Record, -) => Promise; - -type HostRequestType = - | "add_marketplace" - | "check_git_status" - | "checkout_branch" - | "close_plan_preview" - | "create_new_browser_tab" - | "disable_chrome_mcp" - | "disable_jupyter_mcp" - | "dismiss_onboarding" - | "dismiss_review_upsell_banner" - | "dismiss_terminal_banner" - | "enable_jupyter_mcp" - | "ensure_chrome_mcp_enabled" - | "exec" - | "fork_conversation" - | "get_asset_uris" - | "get_claude_state" - | "get_current_selection" - | "get_mcp_servers" - | "get_session_request" - | "get_terminal_contents" - | "init" - | "install_plugin" - | "list_files_request" - | "list_marketplaces" - | "list_plugins" - | "list_remote_sessions" - | "list_sessions_request" - | "log_event" - | "login" - | "new_conversation_tab" - | "open_claude_in_terminal" - | "open_config" - | "open_config_file" - | "open_content" - | "open_diff" - | "open_file" - | "open_file_diffs" - | "open_folder" - | "open_help" - | "open_markdown_preview" - | "open_output_panel" - | "open_terminal" - | "open_url" - | "reconnect_mcp_server" - | "refresh_marketplace" - | "remove_marketplace" - | "remove_plan_comment" - | "rename_tab" - | "request_usage_update" - | "rewind_code" - | "set_mcp_server_enabled" - | "set_model" - | "set_permission_mode" - | "set_plugin_enabled" - | "set_thinking_level" - | "show_claude_terminal_setting" - | "show_notification" - | "submit_oauth_code" - | "teleport_session" - | "uninstall_plugin" - | "update_skipped_branch"; - -const KNOWN_HOST_REQUEST_TYPES: Set = new Set([ - "add_marketplace", - "check_git_status", - "checkout_branch", - "close_plan_preview", - "create_new_browser_tab", - "disable_chrome_mcp", - "disable_jupyter_mcp", - "dismiss_onboarding", - "dismiss_review_upsell_banner", - "dismiss_terminal_banner", - "enable_jupyter_mcp", - "ensure_chrome_mcp_enabled", - "exec", - "fork_conversation", - "get_asset_uris", - "get_claude_state", - "get_current_selection", - "get_mcp_servers", - "get_session_request", - "get_terminal_contents", - "init", - "install_plugin", - "list_files_request", - "list_marketplaces", - "list_plugins", - "list_remote_sessions", - "list_sessions_request", - "log_event", - "login", - "new_conversation_tab", - "open_claude_in_terminal", - "open_config", - "open_config_file", - "open_content", - "open_diff", - "open_file", - "open_file_diffs", - "open_folder", - "open_help", - "open_markdown_preview", - "open_output_panel", - "open_terminal", - "open_url", - "reconnect_mcp_server", - "refresh_marketplace", - "remove_marketplace", - "remove_plan_comment", - "rename_tab", - "request_usage_update", - "rewind_code", - "set_mcp_server_enabled", - "set_model", - "set_permission_mode", - "set_plugin_enabled", - "set_thinking_level", - "show_claude_terminal_setting", - "show_notification", - "submit_oauth_code", - "teleport_session", - "uninstall_plugin", - "update_skipped_branch", -]); - -interface ClaudeBridgeDeps { - ptyManager?: PtyManager; - invokeRendererOp?: RendererHostOpInvoker; -} - -interface PendingWebviewRequest { - requestType: string; - sessionId?: string; - resolve: (response: Record) => void; - reject: (error: Error) => void; - timer: NodeJS.Timeout; - abortSignal?: AbortSignal; - abortHandler?: () => void; -} - -interface IncomingRequestController { - sessionId: string; - controller: AbortController; -} - -export class ClaudeBridge { - private claudeManager: ClaudeManager; - private channels = new Map(); - private incomingRequestControllers = new Map(); - private pendingWebviewRequests = new Map(); - private claudeExePath: string | null = null; - private cachedCredentials: ClaudeCredentials | null = null; - private ptyManager?: PtyManager; - private invokeRendererOp?: RendererHostOpInvoker; - private terminalAliases = new Map(); - private defaultTerminalSessionId: string | null = null; - private modelSetting = "default"; - private thinkingLevel = "default"; - private permissionMode = "default"; - - constructor(claudeManager: ClaudeManager, deps: ClaudeBridgeDeps = {}) { - this.claudeManager = claudeManager; - this.ptyManager = deps.ptyManager; - this.invokeRendererOp = deps.invokeRendererOp; - } - - private readCredentials(): ClaudeCredentials | null { - if (this.cachedCredentials) return this.cachedCredentials; - - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - const credPath = join(homeDir, ".claude", ".credentials.json"); - - try { - if (existsSync(credPath)) { - const raw = readFileSync(credPath, "utf-8"); - this.cachedCredentials = JSON.parse(raw); - return this.cachedCredentials; - } - } catch { - /* ignore */ - } - - return null; - } - - private findClaudeExe(): string | null { - if (this.claudeExePath) return this.claudeExePath; - - const extPath = this.claudeManager.findClaudeExtension(); - if (!extPath) return null; - - const exePath = join(extPath, "resources", "native-binary", "claude.exe"); - if (existsSync(exePath)) { - this.claudeExePath = exePath; - return exePath; - } - - return null; - } - - handleWebviewMessage(sessionId: string, message: any): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - switch (message.type) { - case "response": - this.handleWebviewResponse(message); - break; - case "cancel_request": - this.handleCancelRequest(message); - break; - case "request": - void this.handleRequest(sessionId, wc, message); - break; - case "launch_claude": - this.launchClaude(sessionId, wc, message.channelId, message.model); - break; - case "io_message": - this.handleIoMessage(message.channelId, message.message); - break; - case "start_speech_to_text": - this.handleSpeechToTextNotSupported(wc, message.channelId); - break; - case "stop_speech_to_text": - this.handleSpeechToTextNotSupported(wc, message.channelId); - break; - case "interrupt_claude": - this.interruptClaude(message.channelId); - break; - case "close_channel": - this.closeChannel(message.channelId); - break; - } - } - - notifyVisibilityChanged(sessionId: string, isVisible: boolean): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest(wc, { type: "visibility_changed", isVisible }); - } - - notifySelectionChanged(sessionId: string, selection: unknown): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest(wc, { type: "selection_changed", selection }); - } - - emitUpdateState(sessionId: string, channelId?: string): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest( - wc, - { - type: "update_state", - state: this.buildInitState(), - config: this.buildClaudeConfig(), - }, - channelId, - ); - } - - emitUsageUpdate( - sessionId: string, - utilization: unknown = null, - error: unknown = null, - channelId?: string, - ): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest( - wc, - { - type: "usage_update", - utilization, - error, - }, - channelId, - ); - } - - emitAuthUrl(sessionId: string, url: string, channelId?: string): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest( - wc, - { - type: "auth_url", - url, - }, - channelId, - ); - } - - emitCreateNewConversation(sessionId: string, channelId?: string): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest(wc, { type: "create_new_conversation" }, channelId); - } - - emitOpenPluginsDialog( - sessionId: string, - payload: { pluginName?: string; marketplaceSource?: string } = {}, - channelId?: string, - ): void { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) return; - - this.emitWebviewRequest( - wc, - { - type: "open_plugins_dialog", - ...(payload.pluginName ? { pluginName: payload.pluginName } : {}), - ...(payload.marketplaceSource ? { marketplaceSource: payload.marketplaceSource } : {}), - }, - channelId, - ); - } - - async requestToolPermission( - sessionId: string, - params: { - channelId: string; - toolName: string; - inputs?: Record; - suggestions?: unknown[]; - timeoutMs?: number; - signal?: AbortSignal; - }, - ): Promise> { - const wc = this.claudeManager.getWebContents(sessionId); - if (!wc) { - return { - type: "tool_permission_response", - result: { behavior: "deny", message: "Session is not available", interrupt: false }, - }; - } - - try { - const response = await this.requestWebviewResponse( - wc, - { - type: "tool_permission_request", - toolName: params.toolName, - inputs: params.inputs ?? {}, - suggestions: params.suggestions ?? [], - }, - { - sessionId, - channelId: params.channelId, - timeoutMs: params.timeoutMs ?? 120000, - signal: params.signal, - }, - ); - return response; - } catch (err) { - const errorText = err instanceof Error ? err.message : String(err); - return { - type: "tool_permission_response", - result: { behavior: "deny", message: errorText, interrupt: false }, - }; - } - } - - private handleWebviewResponse(message: any): void { - const requestId = typeof message?.requestId === "string" ? message.requestId : ""; - if (!requestId) return; - - const pending = this.pendingWebviewRequests.get(requestId); - if (!pending) return; - - clearTimeout(pending.timer); - this.pendingWebviewRequests.delete(requestId); - - if (pending.abortSignal && pending.abortHandler) { - pending.abortSignal.removeEventListener("abort", pending.abortHandler); - } - - const response = - message?.response && typeof message.response === "object" - ? (message.response as Record) - : {}; - - if (response.type === "error") { - const errorText = - typeof response.error === "string" - ? response.error - : `Webview request '${pending.requestType}' failed`; - pending.reject(new Error(errorText)); - return; - } - - pending.resolve(response); - } - - private handleCancelRequest(message: any): void { - const targetRequestId = - typeof message?.targetRequestId === "string" ? message.targetRequestId : ""; - if (!targetRequestId) return; - - const entry = this.incomingRequestControllers.get(targetRequestId); - if (!entry) return; - - entry.controller.abort(); - this.incomingRequestControllers.delete(targetRequestId); - } - - private handleSpeechToTextNotSupported(wc: WebContents, channelId: unknown): void { - if (typeof channelId !== "string" || !channelId) return; - - this.sendToWebview(wc, { - type: "close_channel", - channelId, - error: "Speech-to-text is not supported in LiteEditor yet.", - }); - } - - private emitWebviewRequest( - wc: WebContents, - request: Record, - channelId?: string, - ): void { - this.sendToWebview(wc, { - type: "request", - ...(channelId ? { channelId } : {}), - request, - }); - } - - private requestWebviewResponse( - wc: WebContents, - request: Record, - options: { - sessionId?: string; - channelId?: string; - timeoutMs?: number; - signal?: AbortSignal; - } = {}, - ): Promise> { - const requestType = typeof request.type === "string" ? request.type : "unknown"; - const requestId = `claude-host-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const timeoutMs = options.timeoutMs ?? 30000; - - if (wc.isDestroyed()) { - return Promise.reject(new Error(`Webview is destroyed; cannot send '${requestType}'`)); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const pending = this.pendingWebviewRequests.get(requestId); - if (pending?.abortSignal && pending.abortHandler) { - pending.abortSignal.removeEventListener("abort", pending.abortHandler); - } - this.pendingWebviewRequests.delete(requestId); - reject(new Error(`Timed out waiting for webview response to '${requestType}'`)); - }, timeoutMs); - - const pending: PendingWebviewRequest = { - requestType, - sessionId: options.sessionId, - resolve, - reject, - timer, - abortSignal: options.signal, - }; - - if (options.signal) { - const abortHandler = () => { - clearTimeout(timer); - this.pendingWebviewRequests.delete(requestId); - this.sendToWebview(wc, { type: "cancel_request", targetRequestId: requestId }); - reject(new Error(`Webview request '${requestType}' aborted`)); - }; - pending.abortHandler = abortHandler; - if (options.signal.aborted) { - abortHandler(); - return; - } - options.signal.addEventListener("abort", abortHandler, { once: true }); - } - - this.pendingWebviewRequests.set(requestId, pending); - - this.sendToWebview(wc, { - type: "request", - requestId, - ...(options.channelId ? { channelId: options.channelId } : {}), - request, - }); - }); - } - - private async handleRequest(sessionId: string, wc: WebContents, message: any): Promise { - const requestType = String(message.request?.type || message.requestType || ""); - const requestId = String(message.requestId || ""); - const request = ( - message.request && typeof message.request === "object" ? message.request : {} - ) as Record; - const channelId = typeof message.channelId === "string" ? message.channelId : undefined; - - if (!requestId) { - console.warn("[claude-bridge] Request dropped without requestId"); - return; - } - - if (!requestType) { - this.sendResponseError(wc, requestId, "Missing request type"); - return; - } - - const abortController = new AbortController(); - this.incomingRequestControllers.set(requestId, { sessionId, controller: abortController }); - - try { - if (!KNOWN_HOST_REQUEST_TYPES.has(requestType as HostRequestType)) { - console.warn(`[claude-bridge] Unknown request type '${requestType}'`, { channelId }); - this.sendResponseOk(wc, requestId, this.createSafeStub(requestType)); - return; - } - - const response = await this.handleKnownRequest( - requestType as HostRequestType, - sessionId, - wc, - request, - channelId, - abortController.signal, - ); - if (abortController.signal.aborted) return; - this.sendResponseOk(wc, requestId, response); - } catch (err) { - if (abortController.signal.aborted) { - console.warn(`[claude-bridge] Request '${requestType}' was cancelled`, { - requestId, - channelId, - }); - return; - } - const error = err instanceof Error ? err.message : String(err); - console.error(`[claude-bridge] Failed request '${requestType}'`, { error, channelId }); - this.sendResponseError(wc, requestId, error); - } finally { - this.incomingRequestControllers.delete(requestId); - } - } - - private async handleKnownRequest( - requestType: HostRequestType, - sessionId: string, - wc: WebContents, - request: Record, - channelId?: string, - _signal?: AbortSignal, - ): Promise> { - switch (requestType) { - case "init": - return { state: this.buildInitState() }; - case "get_claude_state": - return { config: this.buildClaudeConfig() }; - case "request_usage_update": - this.emitUsageUpdate(sessionId, null, null, channelId); - return {}; - case "list_sessions_request": - return { sessions: await this.listSessions() }; - case "list_remote_sessions": - return { sessions: [] }; - case "list_files_request": - return { files: [] }; - case "get_session_request": { - const targetSessionId = typeof request.sessionId === "string" ? request.sessionId : ""; - return this.getSessionMessages(targetSessionId); - } - case "set_model": { - const model = this.resolveModelValue(request.model); - if (!model) { - return { type: "set_model_response", success: false, error: "Missing model value" }; - } - - this.modelSetting = model; - - // The LiteEditor bridge cannot hot-swap model on a live claude.exe stream. - // Close the current channel so the next user send relaunches Claude with the new model. - if (channelId && this.channels.has(channelId)) { - this.closeChannel(channelId); - this.sendToWebview(wc, { type: "close_channel", channelId }); - } - - this.emitUpdateState(sessionId, channelId); - return { type: "set_model_response", success: true }; - } - case "set_thinking_level": { - const thinkingLevel = - typeof request.thinkingLevel === "string" ? request.thinkingLevel : null; - if (thinkingLevel) this.thinkingLevel = thinkingLevel; - this.emitUpdateState(sessionId, channelId); - return { type: "set_thinking_level_response", success: true }; - } - case "set_permission_mode": { - const mode = typeof request.mode === "string" ? request.mode : null; - if (mode) this.permissionMode = mode; - return { success: true }; - } - case "open_url": { - const url = typeof request.url === "string" ? request.url : null; - if (url) await shell.openExternal(url); - return { success: true }; - } - case "open_folder": { - const mainWindow = this.claudeManager.getMainWindow(sessionId); - if (!mainWindow) return { opened: false }; - const result = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory"] }); - if (result.canceled || result.filePaths.length === 0) return { opened: false }; - await this.invokeRendererHostOp("open_folder", { path: result.filePaths[0] }); - return { opened: true, path: result.filePaths[0] }; - } - case "open_file": - await this.invokeRendererHostOp("open_file", request); - return {}; - case "open_content": { - const result = await this.invokeRendererHostOp("open_content", request); - return { updatedContent: result.updatedContent ?? request.content ?? "" }; - } - case "open_diff": { - const result = await this.invokeRendererHostOp("open_diff", request); - return { newEdits: result.newEdits ?? request.edits ?? [] }; - } - case "open_file_diffs": - await this.invokeRendererHostOp("open_file_diffs", request); - return {}; - case "get_current_selection": { - const result = await this.invokeRendererHostOp("get_current_selection"); - return { selection: result.selection ?? "" }; - } - case "new_conversation_tab": { - const result = await this.invokeRendererHostOp("new_conversation_tab", request); - return { success: true, ...(result.sessionId ? { sessionId: result.sessionId } : {}) }; - } - case "rename_tab": - await this.invokeRendererHostOp("rename_tab", request); - return { success: true }; - case "check_git_status": { - const result = await this.invokeRendererHostOp("check_git_status"); - return Object.keys(result).length > 0 - ? result - : { isClean: true, hasChanges: false, branch: null }; - } - case "checkout_branch": { - const result = await this.invokeRendererHostOp("checkout_branch", request); - return Object.keys(result).length > 0 - ? result - : { status: "failed", branch: request.branch ?? null }; - } - case "update_skipped_branch": - return { success: true }; - case "open_output_panel": - await this.invokeRendererHostOp("open_output_panel"); - return { success: true }; - case "open_config": - await this.invokeRendererHostOp("open_config", request); - return { success: true }; - case "open_help": - await shell.openExternal("https://code.claude.com/docs/en/vs-code"); - return { success: true }; - case "show_notification": - return this.handleShowNotification(sessionId, request); - case "open_terminal": - return this.handleOpenTerminal(request); - case "open_claude_in_terminal": - return this.handleOpenClaudeInTerminal(request); - case "exec": - return this.handleExecInTerminal(request); - case "get_terminal_contents": - return this.handleGetTerminalContents(request); - case "login": - return { auth: this.buildAuthStatus() }; - case "submit_oauth_code": - return { success: true }; - case "get_asset_uris": - return { assetUris: {} }; - case "log_event": - console.log("[claude-bridge] log_event", { - channelId, - eventName: typeof request.eventName === "string" ? request.eventName : "unknown", - }); - return { success: true }; - case "fork_conversation": { - const result = await this.invokeRendererHostOp("fork_conversation", request); - return { sessionId: result.sessionId ?? request.forkedFromSession ?? "" }; - } - case "teleport_session": - return this.createSafeStub(requestType, { success: false }); - case "list_plugins": - return this.createSafeStub(requestType, { installedPlugins: [], availablePlugins: [] }); - case "list_marketplaces": - return this.createSafeStub(requestType, { marketplaces: [] }); - case "get_mcp_servers": - return this.createSafeStub(requestType, { servers: [] }); - case "install_plugin": - case "uninstall_plugin": - case "set_plugin_enabled": - case "add_marketplace": - case "remove_marketplace": - case "refresh_marketplace": - case "set_mcp_server_enabled": - case "reconnect_mcp_server": - case "ensure_chrome_mcp_enabled": - case "disable_chrome_mcp": - case "enable_jupyter_mcp": - case "disable_jupyter_mcp": - case "open_config_file": - case "open_markdown_preview": - case "close_plan_preview": - case "remove_plan_comment": - case "create_new_browser_tab": - case "show_claude_terminal_setting": - case "dismiss_terminal_banner": - case "dismiss_review_upsell_banner": - case "dismiss_onboarding": - case "rewind_code": - return this.createSafeStub(requestType, { success: true }); - default: - return this.createSafeStub(requestType); - } - } - - private buildInitState(): Record { - return { - defaultCwd: process.cwd(), - platform: process.platform, - openNewInTab: true, - isOnboardingDismissed: true, - initialPermissionMode: this.permissionMode, - modelSetting: this.modelSetting, - thinkingLevel: this.thinkingLevel, - speechToTextEnabled: false, - browserIntegrationSupported: false, - protocolVersion: "liteeditor-claude-bridge/1", - chromeMcpState: { status: "disconnected" }, - debuggerMcpState: { status: "not_installed" }, - jupyterMcpState: { status: "not_installed" }, - authStatus: this.buildAuthStatus(), - }; - } - - private buildClaudeConfig(): Record { - const auth = this.buildAuthStatus(); - const hasAuth = !!auth; - return { - account: { - tokenSource: hasAuth ? "claude_ai_oauth" : "none", - subscriptionType: auth?.subscriptionType ?? null, - authenticated: hasAuth, - }, - models: [ - { - value: "default", - displayName: "Default (recommended)", - description: "Opus 4.6 - Most capable for complex work", - }, - { - value: "claude-opus-4-6-max-1m", - displayName: "Opus (1M context)", - description: "Opus 4.6 with 1M context - Billed as extra usage - $10/$37.50 per Mtok", - }, - { - value: "sonnet", - displayName: "Sonnet", - description: "Sonnet 4.6 - Best for everyday tasks", - }, - { - value: "claude-sonnet-4-6-max-1m", - displayName: "Sonnet (1M context)", - description: "Sonnet 4.6 with 1M context - Billed as extra usage - $6/$22.50 per Mtok", - }, - { - value: "haiku", - displayName: "Haiku", - description: "Haiku 4.5 - Fastest for quick answers", - }, - ], - commands: [ - { id: "fast", label: "/fast", description: "Toggle fast mode (Opus 4.6 only)" }, - { id: "login", label: "/login", description: "Switch account" }, - ], - }; - } - - private buildAuthStatus(): Record | null { - const creds = this.readCredentials(); - const hasAuth = !!creds?.claudeAiOauth?.accessToken; - if (!hasAuth) return null; - return { - authenticated: true, - authMethod: "oauth", - tokenSource: "claude_ai_oauth", - subscriptionType: creds?.claudeAiOauth?.subscriptionType || null, - rateLimitTier: creds?.claudeAiOauth?.rateLimitTier || null, - }; - } - - private async getSessionMessages(sessionId: string): Promise> { - try { - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - const projectsDir = join(homeDir, ".claude", "projects"); - - if (!existsSync(projectsDir)) { - return { messages: [], sessionDiffs: [] }; - } - - let jsonlPath: string | null = null; - const projectDirs = readdirSync(projectsDir); - - for (const projDir of projectDirs) { - const candidate = join(projectsDir, projDir, `${sessionId}.jsonl`); - if (existsSync(candidate)) { - jsonlPath = candidate; - break; - } - } - - if (!jsonlPath) { - return { messages: [], sessionDiffs: [] }; - } - - const messages: any[] = []; - const raw = readFileSync(jsonlPath, "utf-8"); - const jsonlLines = raw.split("\n"); - - for (const line of jsonlLines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - messages.push(JSON.parse(trimmed)); - } catch {} - } - - return { messages, sessionDiffs: [] }; - } catch { - return { messages: [], sessionDiffs: [] }; - } - } - - private sendResponseOk( - wc: WebContents, - requestId: string, - response: Record, - ): void { - this.sendToWebview(wc, { - type: "response", - requestId, - response, - }); - } - - private sendResponseError( - wc: WebContents, - requestId: string, - error: string, - code?: string, - ): void { - this.sendToWebview(wc, { - type: "response", - requestId, - response: { - type: "error", - error, - ...(code ? { code } : {}), - }, - }); - } - - private createSafeStub( - requestType: string, - payload: Record = {}, - ): Record { - return { - success: true, - implemented: false, - requestType, - ...payload, - }; - } - - private resolveModelValue(model: unknown): string | null { - if (typeof model === "string" && model.trim().length > 0) { - return model.trim(); - } - if (model && typeof model === "object") { - const value = (model as { value?: unknown }).value; - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - } - return null; - } - - private async invokeRendererHostOp( - op: string, - payload: Record = {}, - ): Promise { - if (!this.invokeRendererOp) return {}; - try { - return await this.invokeRendererOp(op, payload); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.warn(`[claude-bridge] Renderer host op failed '${op}'`, { error }); - return {}; - } - } - - private async handleShowNotification( - sessionId: string, - request: Record, - ): Promise> { - const message = typeof request.message === "string" ? request.message : "Notification"; - const detail = typeof request.detail === "string" ? request.detail : undefined; - const severity = typeof request.severity === "string" ? request.severity : "info"; - const buttons = this.toStringArray(request.buttons); - const mainWindow = this.claudeManager.getMainWindow(sessionId); - - const type: Electron.MessageBoxOptions["type"] = - severity === "error" ? "error" : severity === "warning" ? "warning" : "info"; - - const fallbackButtons = buttons.length > 0 ? buttons : ["OK"]; - const response = mainWindow - ? await dialog.showMessageBox(mainWindow, { - type, - message, - detail, - buttons: fallbackButtons, - defaultId: 0, - cancelId: 0, - }) - : await dialog.showMessageBox({ - type, - message, - detail, - buttons: fallbackButtons, - defaultId: 0, - cancelId: 0, - }); - - return { buttonValue: fallbackButtons[response.response] ?? null }; - } - - private async handleOpenTerminal( - request: Record, - ): Promise> { - if (!this.ptyManager) return this.createSafeStub("open_terminal", { opened: false }); - - const terminalName = - typeof request.terminalName === "string" ? request.terminalName : undefined; - const cwd = typeof request.cwd === "string" ? request.cwd : undefined; - const executable = typeof request.executable === "string" ? request.executable : undefined; - const args = this.toStringArray(request.args); - - const sessionId = this.ensureTerminalSession(terminalName, cwd, executable); - if (!sessionId) return this.createSafeStub("open_terminal", { opened: false }); - - await this.attachTerminalSession(sessionId, cwd, executable); - - if (args.length > 0) { - this.ptyManager.write(sessionId, `${args.join(" ")}\r`); - } - - return { - opened: true, - terminalName: terminalName ?? sessionId, - sessionId, - }; - } - - private async handleOpenClaudeInTerminal( - request: Record, - ): Promise> { - if (!this.ptyManager) return this.createSafeStub("open_claude_in_terminal", { opened: false }); - - const prompt = typeof request.prompt === "string" ? request.prompt : ""; - const args = this.toStringArray(request.args); - const cwd = typeof request.cwd === "string" ? request.cwd : undefined; - const terminalName = typeof request.terminalName === "string" ? request.terminalName : "claude"; - - const sessionId = this.ensureTerminalSession(terminalName, cwd); - if (!sessionId) return this.createSafeStub("open_claude_in_terminal", { opened: false }); - - await this.attachTerminalSession(sessionId, cwd, "claude"); - - const commandLine = ["claude", ...args, ...(prompt ? [prompt] : [])].join(" "); - this.ptyManager.write(sessionId, `${commandLine}\r`); - - return { - opened: true, - terminalName, - sessionId, - command: commandLine, - }; - } - - private async handleExecInTerminal( - request: Record, - ): Promise> { - if (!this.ptyManager) return this.createSafeStub("exec", { success: false }); - - const command = typeof request.command === "string" ? request.command : ""; - if (!command) return { success: false, error: "Missing command" }; - - const terminalName = typeof request.terminalName === "string" ? request.terminalName : "claude"; - const cwd = typeof request.cwd === "string" ? request.cwd : undefined; - const sessionId = this.ensureTerminalSession(terminalName, cwd); - if (!sessionId) return { success: false, error: "No terminal session available" }; - - await this.attachTerminalSession(sessionId, cwd); - - const params = Array.isArray(request.params) ? request.params.map(String) : []; - const commandLine = [command, ...params].join(" "); - this.ptyManager.write(sessionId, `${commandLine}\r`); - - return { - success: true, - terminalName, - sessionId, - output: this.ptyManager.readOutput(sessionId) ?? "", - }; - } - - private async handleGetTerminalContents( - request: Record, - ): Promise> { - if (!this.ptyManager) return this.createSafeStub("get_terminal_contents", { contents: null }); - - const terminalName = - typeof request.terminalName === "string" ? request.terminalName : undefined; - const sessionId = this.resolveTerminalSession(terminalName); - if (!sessionId) { - return { - terminalName: terminalName ?? null, - sessionId: null, - contents: null, - }; - } - - return { - terminalName: terminalName ?? sessionId, - sessionId, - contents: this.ptyManager.readOutput(sessionId) ?? "", - }; - } - - private resolveTerminalSession(terminalName?: string): string | null { - if (!this.ptyManager) return null; - - const sessions = this.ptyManager.listSessions(); - const isAlive = (id: string): boolean => sessions.some((session) => session.id === id); - - if (terminalName) { - const alias = this.terminalAliases.get(terminalName); - if (alias && isAlive(alias)) return alias; - if (isAlive(terminalName)) { - this.terminalAliases.set(terminalName, terminalName); - return terminalName; - } - } - - if (this.defaultTerminalSessionId && isAlive(this.defaultTerminalSessionId)) { - return this.defaultTerminalSessionId; - } - - return sessions.length > 0 ? sessions[0].id : null; - } - - private ensureTerminalSession( - terminalName?: string, - cwd?: string, - executable?: string, - ): string | null { - if (!this.ptyManager) return null; - - const existing = this.resolveTerminalSession(terminalName); - if (existing) { - if (terminalName) this.terminalAliases.set(terminalName, existing); - this.defaultTerminalSessionId = existing; - return existing; - } - - const sessionId = this.ptyManager.create(executable, cwd); - if (terminalName) this.terminalAliases.set(terminalName, sessionId); - this.defaultTerminalSessionId = sessionId; - return sessionId; - } - - private async attachTerminalSession( - sessionId: string, - cwd?: string, - shellName?: string, - ): Promise { - if (!this.invokeRendererOp) return; - await this.invokeRendererHostOp("attach_terminal_session", { - sessionId, - ...(cwd ? { cwd } : {}), - ...(shellName ? { shell: shellName } : {}), - }); - } - - private toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.map((item) => String(item)); - } - private async listSessions(): Promise { - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - const projectsDir = join(homeDir, ".claude", "projects"); - - if (!existsSync(projectsDir)) return []; - - const sessions: any[] = []; - - try { - const projectDirs = readdirSync(projectsDir); - - for (const projDir of projectDirs) { - const projPath = join(projectsDir, projDir); - try { - const stat = statSync(projPath); - if (!stat.isDirectory()) continue; - } catch { - continue; - } - - // Find .jsonl session files in this project - let files: string[]; - try { - files = readdirSync(projPath).filter((f) => f.endsWith(".jsonl")); - } catch { - continue; - } - - for (const file of files) { - const sessionId = file.replace(".jsonl", ""); - const filePath = join(projPath, file); - - try { - const stat = statSync(filePath); - const fileSize = stat.size; - - // Read first user message for summary - const summary = await this.getSessionSummary(filePath); - - sessions.push({ - id: sessionId, - lastModified: stat.mtimeMs, - summary: summary || "Untitled", - isCurrentWorkspace: true, - worktree: null, - gitBranch: null, - fileSize, - projectDir: projDir, - }); - } catch { - /* skip unreadable files */ - } - } - } - } catch { - /* projects dir not readable */ - } - - // Sort by lastModified descending (newest first) - sessions.sort((a, b) => b.lastModified - a.lastModified); - - return sessions; - } - - private getSessionSummary(jsonlPath: string): Promise { - return new Promise((resolve) => { - let summary: string | null = null; - let lineCount = 0; - - const rl = createInterface({ - input: createReadStream(jsonlPath, { encoding: "utf-8" }), - crlfDelay: Infinity, - }); - - rl.on("line", (line) => { - lineCount++; - // Only scan first 50 lines to find a user message - if (lineCount > 50) { - rl.close(); - return; - } - - try { - const obj = JSON.parse(line); - if (obj.type === "user" && obj.message?.content && !summary) { - const content = obj.message.content; - if (typeof content === "string") { - summary = content.slice(0, 100); - } else if (Array.isArray(content)) { - const textBlock = content.find((b: any) => b.type === "text" && b.text); - if (textBlock) { - summary = textBlock.text.slice(0, 100); - } - } - rl.close(); - } - } catch { - /* skip non-JSON lines */ - } - }); - - rl.on("close", () => resolve(summary)); - rl.on("error", () => resolve(null)); - }); - } - - private launchClaude( - sessionId: string, - wc: WebContents, - channelId: string, - model?: string, - ): void { - const exePath = this.findClaudeExe(); - if (!exePath) { - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: { type: "system", text: "claude.exe not found in extension directory." }, - done: true, - }); - return; - } - - // Kill existing process on this channel - this.closeChannel(channelId); - - const args = ["--output-format", "stream-json", "--input-format", "stream-json", "--verbose"]; - - // Add model flag if not default - if (model && model !== "default") { - args.push("--model", model); - } - - args.push("-p"); - - const proc = spawn(exePath, args, { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - env: { ...process.env }, - }); - - this.channels.set(channelId, { proc, sessionId }); - - let stdoutBuffer = ""; - - proc.stdout?.on("data", (chunk: Buffer) => { - stdoutBuffer += chunk.toString(); - - // Process NDJSON lines - let newlineIdx: number; - while ((newlineIdx = stdoutBuffer.indexOf("\n")) >= 0) { - const line = stdoutBuffer.slice(0, newlineIdx).trim(); - stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1); - - if (!line) continue; - - try { - const parsed = JSON.parse(line); - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: parsed, - done: false, - }); - } catch { - // Non-JSON output — send as system message - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: { type: "system", text: line }, - done: false, - }); - } - } - }); - - proc.stderr?.on("data", (chunk: Buffer) => { - const text = chunk.toString().trim(); - if (text) { - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: { type: "system", text }, - done: false, - }); - } - }); - - proc.on("exit", (code) => { - this.channels.delete(channelId); - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: { type: "system", text: `Process exited with code ${code}` }, - done: true, - }); - }); - - proc.on("error", (err) => { - this.channels.delete(channelId); - this.sendToWebview(wc, { - type: "io_message", - channelId, - message: { type: "system", text: `Error: ${err.message}` }, - done: true, - }); - }); - } - - private handleIoMessage(channelId: string, message: any): void { - const channel = this.channels.get(channelId); - if (!channel || !channel.proc.stdin?.writable) return; - - try { - channel.proc.stdin.write(JSON.stringify(message) + "\n"); - } catch { - /* stdin may be closed */ - } - } - - private interruptClaude(channelId: string): void { - const channel = this.channels.get(channelId); - if (!channel) return; - - try { - if (process.platform === "win32") { - // On Windows, send Ctrl+C equivalent via taskkill - const pid = channel.proc.pid; - if (pid) { - try { - // Generate a CTRL_C_EVENT for the process - channel.proc.kill("SIGINT"); - } catch { - /* */ - } - } - } else { - channel.proc.kill("SIGINT"); - } - } catch { - /* process may already be dead */ - } - } - - private closeChannel(channelId: string): void { - const channel = this.channels.get(channelId); - if (!channel) return; - - this.channels.delete(channelId); - - try { - const pid = channel.proc.pid; - channel.proc.kill(); - - // Windows: force-kill the process tree - if (pid && process.platform === "win32") { - try { - execSync(`taskkill /pid ${pid} /T /F`, { windowsHide: true, stdio: "ignore" }); - } catch { - /* already dead */ - } - } - } catch { - /* process may already be dead */ - } - } - - private sendToWebview(wc: WebContents, message: any): void { - try { - wc.send("claude:host-message", message); - } catch { - /* webcontents may be destroyed */ - } - } - - shutdownSession(sessionId: string): void { - for (const [requestId, entry] of Array.from(this.incomingRequestControllers.entries())) { - if (entry.sessionId !== sessionId) continue; - entry.controller.abort(); - this.incomingRequestControllers.delete(requestId); - } - - for (const [requestId, pending] of Array.from(this.pendingWebviewRequests.entries())) { - if (pending.sessionId !== sessionId) continue; - clearTimeout(pending.timer); - if (pending.abortSignal && pending.abortHandler) { - pending.abortSignal.removeEventListener("abort", pending.abortHandler); - } - pending.reject(new Error(`Claude session '${sessionId}' closed`)); - this.pendingWebviewRequests.delete(requestId); - } - - for (const [channelId, channel] of Array.from(this.channels.entries())) { - if (channel.sessionId !== sessionId) continue; - this.closeChannel(channelId); - } - } - - shutdown(): void { - this.incomingRequestControllers.forEach((entry) => entry.controller.abort()); - this.incomingRequestControllers.clear(); - - this.pendingWebviewRequests.forEach((pending) => { - clearTimeout(pending.timer); - if (pending.abortSignal && pending.abortHandler) { - pending.abortSignal.removeEventListener("abort", pending.abortHandler); - } - pending.reject(new Error("Claude bridge shutdown")); - }); - this.pendingWebviewRequests.clear(); - - for (const channelId of Array.from(this.channels.keys())) { - this.closeChannel(channelId); - } - } -} diff --git a/apps/desktop/src/src/liteeditor/services/claude-manager.ts b/apps/desktop/src/src/liteeditor/services/claude-manager.ts deleted file mode 100644 index 53d2d70..0000000 --- a/apps/desktop/src/src/liteeditor/services/claude-manager.ts +++ /dev/null @@ -1,175 +0,0 @@ -// @ts-nocheck -import { WebContentsView, BrowserWindow, app } from "electron"; -import { join } from "path"; -import { writeFileSync, mkdirSync } from "fs"; -import { type NativeViewBounds, toContentBounds } from "./native-view-bounds"; -import { buildWebviewThemeCss } from "./webview-theme"; -import { integrationsManager } from "./integrations-manager"; - -let counter = 0; - -interface ClaudeSession { - id: string; - view: WebContentsView; - mainWindow: BrowserWindow; - hidden: boolean; -} - -export class ClaudeManager { - private sessions = new Map(); - private wrapperHtmlPathByExtension = new Map(); - - findClaudeExtension(): string | null { - return integrationsManager.resolve("claude")?.path ?? null; - } - - private getWrapperHtmlPath(extPath: string): string { - const existing = this.wrapperHtmlPathByExtension.get(extPath); - if (existing) return existing; - - const tmpDir = join(app.getPath("temp"), "liteeditor-claude"); - try { - mkdirSync(tmpDir, { recursive: true }); - } catch { - /* exists */ - } - - const cssUrl = `file:///${extPath.replace(/\\/g, "/")}/webview/index.css`; - const jsUrl = `file:///${extPath.replace(/\\/g, "/")}/webview/index.js`; - const themeCss = buildWebviewThemeCss(); - - const html = ` - - - - - - Claude Code - -${themeCss} - - - -
- - -`; - - const htmlPath = join(tmpDir, `claude-webview-${this.toFileSafe(extPath)}.html`); - writeFileSync(htmlPath, html, "utf-8"); - this.wrapperHtmlPathByExtension.set(extPath, htmlPath); - return htmlPath; - } - - private toFileSafe(value: string): string { - return value.replace(/[\\/:*?"<>|]/g, "_"); - } - - createSession(mainWindow: BrowserWindow): string { - const id = `claude-${++counter}-${Date.now()}`; - - const preloadPath = join(__dirname, "liteeditor-claude-preload.js"); - - const view = new WebContentsView({ - webPreferences: { - preload: preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - // Start at zero bounds until the renderer reports real bounds - view.setBounds({ x: 0, y: 0, width: 0, height: 0 }); - mainWindow.contentView.addChildView(view); - - const session: ClaudeSession = { - id, - view, - mainWindow, - hidden: false, - }; - this.sessions.set(id, session); - - // Send session ID to the preload script once the page loads - view.webContents.on("did-finish-load", () => { - view.webContents.send("claude:set-session-id", id); - }); - - const extPath = this.findClaudeExtension(); - if (extPath) { - const htmlPath = this.getWrapperHtmlPath(extPath); - view.webContents.loadFile(htmlPath); - } else { - view.webContents.loadURL( - 'data:text/html,

Claude Code Extension Not Found

Open Settings -> Integrations to install Claude Code.

', - ); - } - - return id; - } - - setBounds(sessionId: string, bounds: NativeViewBounds): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.view.setBounds(toContentBounds(session.mainWindow, bounds)); - } - - showView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || !session.hidden) return; - session.hidden = false; - try { - session.mainWindow.contentView.addChildView(session.view); - } catch { - /* window may be closed */ - } - } - - hideView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.hidden = true; - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may be closed */ - } - } - - getWebContents(sessionId: string) { - const session = this.sessions.get(sessionId); - if (!session) return undefined; - return session.view.webContents; - } - - getMainWindow(sessionId: string): BrowserWindow | undefined { - const session = this.sessions.get(sessionId); - return session?.mainWindow; - } - - destroySession(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session) return; - this.sessions.delete(sessionId); - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may already be closed */ - } - try { - session.view.webContents.close(); - } catch { - /* already destroyed */ - } - } - - removeAll(): void { - for (const sessionId of Array.from(this.sessions.keys())) { - this.destroySession(sessionId); - } - } -} diff --git a/apps/desktop/src/src/liteeditor/services/codex-bridge.ts b/apps/desktop/src/src/liteeditor/services/codex-bridge.ts deleted file mode 100644 index 4d27d37..0000000 --- a/apps/desktop/src/src/liteeditor/services/codex-bridge.ts +++ /dev/null @@ -1,1399 +0,0 @@ -// @ts-nocheck -import { WebContents, shell, dialog } from "electron"; -import { ChildProcess, spawn } from "child_process"; -import { basename, join } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; -import { CodexManager } from "./codex-manager"; - -interface SharedObjectStore { - [key: string]: unknown; -} - -interface InternalUrlResponse { - status: number; - body?: unknown; - error?: string; -} - -export class CodexBridge { - private codexManager: CodexManager; - private persistedAtomState: Record = {}; - private sharedObjects: SharedObjectStore = {}; - private sharedObjectSubscribers = new Map>(); - private vsContextState = new Map(); - - // Workspace onboarding state - private workspaceRootOptions: { roots: string[]; labels: Record } = { - roots: [], - labels: {}, - }; - private activeWorkspaceRoot: string | null = null; - private globalState = new Map(); - private configurationState = new Map(); - private workspaceStateHydrated = false; - - // codex.exe process management - private proc: ChildProcess | null = null; - private pendingRequests = new Map(); - private rpcCallbacks = new Map void; reject: (e: any) => void }>(); - private rpcCounter = 0; - private lineBuffer = ""; - private connectedWebContents = new Map(); - - // Server initialization tracking - private initializeResolve: (() => void) | null = null; - private initializePromise: Promise | null = null; - private serverInitialized = false; - - constructor(codexManager: CodexManager) { - this.codexManager = codexManager; - this.hydrateWorkspaceState(); - } - - setProjectRoot(root: string | null): void { - this.hydrateWorkspaceState(); - - const normalized = this.normalizeRoot(root); - if (!normalized) return; - - const existingRoots = this.getWorkspaceRoots(); - const roots = [normalized, ...existingRoots.filter((item) => item !== normalized)]; - - this.applyWorkspaceRoots(roots, normalized, true); - this.persistLiteEditorProjectRoot(normalized); - } - - handleWebviewMessage(sessionId: string, message: any): void { - const wc = this.codexManager.getWebContents(sessionId); - if (!wc) return; - - // Track connected webviews for broadcasting notifications - this.connectedWebContents.set(sessionId, wc); - - const msgType = message?.type; - if (!msgType) { - console.log("[codex-bridge] Message without type:", message); - return; - } - - switch (msgType) { - case "ready": - this.handleReady(wc); - break; - - case "persisted-atom-sync-request": - this.sendToWebview(wc, { - type: "persisted-atom-sync", - state: this.persistedAtomState, - }); - break; - - case "persisted-atom-update": { - const key = message.key; - const value = message.value; - if (key) { - this.persistedAtomState[key] = value; - } - this.sendToWebview(wc, { - type: "persisted-atom-updated", - key, - value, - }); - break; - } - - case "persisted-atom-reset": - this.persistedAtomState = {}; - this.sendToWebview(wc, { - type: "persisted-atom-sync", - state: {}, - }); - break; - - case "open-in-browser": - if (message.url) { - shell.openExternal(message.url); - } - break; - - case "log-message": - if (message.level === "error" || message.level === "warning" || message.level === "warn") { - console.log("[codex-webview]", message.level, message.message || message.text || ""); - } - break; - - case "view-focused": - case "set-telemetry-user": - // No-op - break; - - case "shared-object-subscribe": - this.handleSharedObjectSubscribe(sessionId, message); - break; - - case "shared-object-unsubscribe": - this.handleSharedObjectUnsubscribe(sessionId, message); - break; - - case "shared-object-set": - this.handleSharedObjectSet(wc, message); - break; - - case "fetch": - void this.handleFetch(wc, message); - break; - - case "fetch-stream": - void this.handleFetchStream(wc, message); - break; - - case "mcp-request": - void this.handleMcpRequest(wc, message); - break; - - case "mcp-response": { - // Response from webview to a server-initiated request -> forward to codex.exe - const resp = message.message || message.response; - if (resp?.id && this.proc) { - this.writeToServer({ id: resp.id, result: resp.result, error: resp.error }); - } - break; - } - - case "mcp-notification": { - // Notification from webview -> forward to codex.exe - const notif = message.notification || message; - if (notif?.method && this.proc) { - this.writeToServer({ method: notif.method, params: notif.params }); - } - break; - } - - case "electron-update-workspace-root-options": { - const roots = Array.isArray(message.roots) - ? message.roots.filter((root): root is string => typeof root === "string") - : []; - const labels = - message.labels && typeof message.labels === "object" - ? (message.labels as Record) - : {}; - - for (const [key, value] of Object.entries(labels)) { - if (typeof value === "string") { - this.workspaceRootOptions.labels[key] = value; - } - } - - this.applyWorkspaceRoots(roots, this.activeWorkspaceRoot, true); - break; - } - - case "electron-set-active-workspace-root": - this.setProjectRoot(typeof message.root === "string" ? message.root : null); - break; - - case "electron-onboarding-skip-workspace": - this.sendToWebview(wc, { - type: "electron-onboarding-skip-workspace-result", - success: true, - }); - break; - - case "electron-set-window-mode": - console.log("[codex-bridge] electron-set-window-mode:", message.mode); - break; - - case "electron-pick-workspace-root-option": - void this.handlePickWorkspaceRoot(sessionId, wc); - break; - - case "electron-rename-workspace-root-option": { - const { root, label } = message; - if (typeof root === "string" && typeof label === "string" && root.length > 0) { - this.workspaceRootOptions.labels[root] = label; - } - break; - } - - case "worker-request": - case "worker-request-cancel": - console.log(`[codex-bridge] Worker message: ${msgType}`, message.method || ""); - break; - - case "electron-add-new-workspace-root-option": { - const root = this.normalizeRoot(message.root); - if (root) { - const existingRoots = this.getWorkspaceRoots(); - const roots = existingRoots.includes(root) ? existingRoots : [root, ...existingRoots]; - this.applyWorkspaceRoots(roots, this.activeWorkspaceRoot, true); - } - this.sendToWebview(wc, { - type: "electron-add-new-workspace-root-option-result", - success: true, - }); - break; - } - - case "electron-add-ssh-host": - // SSH hosts not supported in LiteEditor - break; - - case "electron-app-state-snapshot-request": - this.sendToWebview(wc, { - type: "electron-app-state-snapshot-response", - state: {}, - }); - break; - - case "electron-onboarding-pick-workspace-or-create-default": { - const activeRoot = this.activeWorkspaceRoot || this.getWorkspaceRoots()[0] || null; - this.sendToWebview(wc, { - type: "electron-onboarding-pick-workspace-or-create-default-result", - success: true, - root: activeRoot, - }); - break; - } - - case "electron-request-microphone-permission": - // Microphone not supported in LiteEditor - this.sendToWebview(wc, { - type: "electron-request-microphone-permission-result", - granted: false, - }); - break; - - case "electron-window-focus-request": { - const mainWindow = this.codexManager.getMainWindow(sessionId); - if (mainWindow) { - mainWindow.focus(); - } - break; - } - - default: - console.log("[codex-bridge] unhandled:", msgType); - break; - } - } - - private handleReady(wc: WebContents): void { - this.hydrateWorkspaceState(); - - // Order matches real Codex extension: font -> prompts -> state - this.sendToWebview(wc, { - type: "chat-font-settings", - chatFontSize: 13, - chatCodeFontSize: 13, - }); - this.sendToWebview(wc, { - type: "custom-prompts-updated", - prompts: [], - }); - this.sendToWebview(wc, { - type: "persisted-atom-sync", - state: this.persistedAtomState, - }); - - // If the server is already initialized, immediately tell this webview - // it's connected so the auth flow can proceed. - if (this.serverInitialized && this.proc) { - this.sendToWebview(wc, { - type: "codex-app-server-connection-changed", - hostId: "local", - state: "connected", - }); - } - } - - // --------------------------------------------------------------------------- - // codex.exe app-server process management - // --------------------------------------------------------------------------- - - private startServer(): void { - if (this.proc) return; - - const extPath = this.codexManager.findCodexExtension(); - if (!extPath) { - console.error("[codex-bridge] Cannot start server: extension not found"); - return; - } - - const codexExe = join(extPath, "bin", "windows-x86_64", "codex.exe"); - if (!existsSync(codexExe)) { - console.error("[codex-bridge] codex.exe not found at:", codexExe); - return; - } - - console.log("[codex-bridge] Starting codex.exe app-server..."); - - // Set up initialization tracking: webview MCP requests are queued until - // the initialize handshake with codex.exe completes. - this.serverInitialized = false; - this.initializePromise = new Promise((resolve) => { - this.initializeResolve = resolve; - }); - - // Auto-resolve initialization after 10s so we never hang forever - setTimeout(() => { - if (!this.serverInitialized) { - console.warn("[codex-bridge] Initialize handshake timed out after 10s, proceeding anyway"); - this.completeInitialization(); - } - }, 10_000); - - this.proc = spawn(codexExe, ["app-server", "--analytics-default-enabled"], { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, RUST_LOG: "warn" }, - }); - - this.proc.stdout?.on("data", (chunk: Buffer) => this.onStdoutData(chunk)); - - this.proc.stderr?.on("data", (chunk: Buffer) => { - console.log("[codex-bridge] stderr:", chunk.toString().trimEnd()); - }); - - this.proc.on("error", (err) => { - console.error("[codex-bridge] Process error:", err); - this.proc = null; - this.completeInitialization(); // Unblock any waiting requests - }); - - this.proc.on("exit", (code, signal) => { - console.log("[codex-bridge] Process exited: code=%s signal=%s", code, signal); - this.proc = null; - this.serverInitialized = false; - this.initializePromise = null; - this.initializeResolve = null; - - // Notify webviews that the server disconnected - this.broadcastToWebviews({ - type: "codex-app-server-connection-changed", - hostId: "local", - state: "disconnected", - }); - - for (const [id, pendingWc] of this.pendingRequests) { - this.sendToWebview(pendingWc, { - type: "mcp-response", - hostId: "local", - message: { id, error: { code: -1, message: "codex.exe process exited" } }, - }); - } - this.pendingRequests.clear(); - - for (const [, cb] of this.rpcCallbacks) { - cb.reject(new Error("codex.exe process exited")); - } - this.rpcCallbacks.clear(); - - this.lineBuffer = ""; - }); - - console.log("[codex-bridge] Sending initialize request..."); - this.writeToServer({ - id: "initialize", - method: "initialize", - params: { - clientInfo: { name: "LiteEditor", title: "LiteEditor", version: "1.0.0" }, - capabilities: { experimentalApi: true }, - }, - }); - } - - private completeInitialization(): void { - if (this.serverInitialized) return; - this.serverInitialized = true; - - if (this.initializeResolve) { - this.initializeResolve(); - this.initializeResolve = null; - } - - // Tell webviews the app-server is connected -- this is what the real - // Codex electron app sends after connecting to codex.exe. Without it - // the AppServerManager stays in 'disconnected' state. - this.broadcastToWebviews({ - type: "codex-app-server-connection-changed", - hostId: "local", - state: "connected", - }); - - console.log("[codex-bridge] Server initialized, notified webviews of connected state"); - } - - private onStdoutData(chunk: Buffer): void { - this.lineBuffer += chunk.toString(); - const lines = this.lineBuffer.split("\n"); - this.lineBuffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - let msg: any; - try { - msg = JSON.parse(trimmed); - } catch { - console.warn("[codex-bridge] Failed to parse JSON payload from server:", trimmed); - continue; - } - - this.handleServerMessage(msg); - } - } - - private handleServerMessage(msg: any): void { - if (msg.id && (msg.result !== undefined || msg.error !== undefined)) { - const cb = this.rpcCallbacks.get(msg.id); - if (cb) { - this.rpcCallbacks.delete(msg.id); - if (msg.error) { - cb.reject(msg.error); - } else { - cb.resolve(msg.result); - } - } else { - const wc = this.pendingRequests.get(msg.id); - this.pendingRequests.delete(msg.id); - if (wc) { - this.sendToWebview(wc, { - type: "mcp-response", - hostId: "local", - message: { id: msg.id, result: msg.result, error: msg.error }, - }); - } else if (msg.id === "initialize") { - console.log("[codex-bridge] Initialize response:", msg.result ? "success" : "error"); - this.completeInitialization(); - } - } - return; - } - - if (msg.id && msg.method) { - this.broadcastToWebviews({ - type: "mcp-request", - hostId: "local", - request: { id: msg.id, method: msg.method, params: msg.params }, - }); - return; - } - - if (msg.method) { - this.broadcastToWebviews({ - type: "mcp-notification", - hostId: "local", - method: msg.method, - params: msg.params, - }); - } - } - - private writeToServer(msg: any): void { - if (!this.proc?.stdin?.writable) return; - - try { - const payload = `${JSON.stringify(msg)}\n`; - this.proc.stdin.write(payload); - } catch (err) { - console.error("[codex-bridge] Failed to write to server:", err); - } - } - - private broadcastToWebviews(message: any): void { - for (const wc of this.connectedWebContents.values()) { - this.sendToWebview(wc, message); - } - } - - // --------------------------------------------------------------------------- - // MCP request/response (extension host RPC -> codex.exe) - // --------------------------------------------------------------------------- - - private async handleMcpRequest(wc: WebContents, message: any): Promise { - const request = message.request; - if (!request?.id || !request?.method) { - console.log("[codex-bridge] mcp-request missing id/method:", message); - return; - } - - const { id, method, params } = request; - - if (!this.proc) { - this.startServer(); - } - - if (!this.proc) { - this.sendToWebview(wc, { - type: "mcp-response", - hostId: "local", - message: { id, error: { code: -1, message: "codex.exe server not available" } }, - }); - return; - } - - // Wait for the initialize handshake to complete before forwarding - // webview requests, otherwise codex.exe may drop them. - if (this.initializePromise) { - await this.initializePromise; - } - - if (!this.proc) { - // Process may have exited while we were waiting - this.sendToWebview(wc, { - type: "mcp-response", - hostId: "local", - message: { - id, - error: { code: -1, message: "codex.exe server exited during initialization" }, - }, - }); - return; - } - - this.pendingRequests.set(id, wc); - this.writeToServer({ id, method, params }); - - // Add a safety timeout for each pending request so the webview never hangs - // indefinitely waiting for a response from codex.exe. - setTimeout(() => { - if (this.pendingRequests.has(id)) { - console.warn("[codex-bridge] MCP request timed out:", method, id); - this.pendingRequests.delete(id); - this.sendToWebview(wc, { - type: "mcp-response", - hostId: "local", - message: { id, error: { code: -1, message: `Request timed out: ${method}` } }, - }); - } - }, 30_000); - } - - /** - * Send a JSON-RPC request to codex.exe and return a Promise for the result. - * Used internally by handleFetch for ipc-request / account-info routing. - * Times out after 30 seconds. - */ - private sendRpcRequest(method: string, params: any): Promise { - return new Promise((resolve, reject) => { - if (!this.proc) { - this.startServer(); - } - if (!this.proc) { - reject(new Error("codex.exe server not available")); - return; - } - - const id = `ipc-${++this.rpcCounter}`; - this.rpcCallbacks.set(id, { resolve, reject }); - this.writeToServer({ id, method, params }); - - setTimeout(() => { - if (this.rpcCallbacks.has(id)) { - this.rpcCallbacks.delete(id); - reject(new Error("RPC timeout")); - } - }, 30_000); - }); - } - - private parseJsonBody(body: string | undefined): Record { - if (!body) return {}; - - try { - const parsed = JSON.parse(body); - if (typeof parsed === "object" && parsed !== null) { - return parsed as Record; - } - } catch { - // Fall through - } - - return {}; - } - - private getInternalPath(url: string): string { - try { - const parsed = new URL(url); - return parsed.pathname.replace(/^\/+/, ""); - } catch { - return url.replace(/^vscode:\/\/codex\/?/, ""); - } - } - - private getInternalUrlResponse(url: string, body: string | undefined): InternalUrlResponse { - this.hydrateWorkspaceState(); - - const path = this.getInternalPath(url); - const parsedBody = this.parseJsonBody(body); - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - const codexHome = process.env.CODEX_HOME || (homeDir ? join(homeDir, ".codex") : ".codex"); - const locale = Intl.DateTimeFormat().resolvedOptions().locale || "en-US"; - - let queryKey: string | null = null; - try { - queryKey = new URL(url).searchParams.get("key"); - } catch { - queryKey = null; - } - - const stateKey = typeof parsedBody.key === "string" ? parsedBody.key : queryKey; - - switch (path) { - case "extension-info": - return { status: 200, body: { version: "26.5304.20706", name: "openai.chatgpt" } }; - - case "codex-home": - return { status: 200, body: { codexHome, worktreesSegment: "worktrees" } }; - - case "os-info": - return { status: 200, body: { platform: process.platform } }; - - case "locale-info": - return { status: 200, body: { ideLocale: locale, systemLocale: locale } }; - - case "active-workspace-roots": { - const roots = this.activeWorkspaceRoot ? [this.activeWorkspaceRoot] : []; - return { status: 200, body: { roots } }; - } - - case "workspace-root-options": { - const roots = this.getWorkspaceRoots(); - const labels: Record = {}; - for (const root of roots) { - labels[root] = this.workspaceRootOptions.labels[root] ?? this.getRootLabel(root); - } - this.workspaceRootOptions.labels = { ...this.workspaceRootOptions.labels, ...labels }; - return { status: 200, body: { roots, labels } }; - } - - case "get-copilot-api-proxy-info": - return { status: 200, body: null }; - - case "mcp-codex-config": - return { status: 200, body: { servers: [] } }; - - case "ide-context": { - const workspaceRoot = - typeof parsedBody.workspaceRoot === "string" - ? parsedBody.workspaceRoot - : (this.activeWorkspaceRoot ?? this.getWorkspaceRoots()[0] ?? null); - return { - status: 200, - body: { - ideContext: { - workspaceRoot, - activeFile: null, - selectedText: null, - openFiles: [], - }, - }, - }; - } - - case "get-configuration": - return { - status: 200, - body: { value: stateKey ? (this.configurationState.get(stateKey) ?? null) : null }, - }; - - case "set-configuration": - if (stateKey) { - this.configurationState.set(stateKey, parsedBody.value ?? null); - } - return { status: 200, body: { success: true } }; - - case "paths-exist": { - const paths = Array.isArray(parsedBody.paths) - ? parsedBody.paths.filter((value): value is string => typeof value === "string") - : []; - const existingPaths = paths - .map((pathValue) => pathValue.replace(/\/+$/, "")) - .filter((pathValue) => existsSync(pathValue)); - return { status: 200, body: { existingPaths } }; - } - - case "child-processes": - return { status: 200, body: { processes: [] } }; - - case "open-in-targets": - return { status: 200, body: this.buildOpenTargetsResponse(parsedBody) }; - - case "has-custom-cli-executable": - return { status: 200, body: { hasCustomCliExecutable: false } }; - - case "get-global-state": - return { - status: 200, - body: { value: stateKey ? (this.globalState.get(stateKey) ?? null) : null }, - }; - - case "set-global-state": - if (stateKey) { - this.globalState.set(stateKey, parsedBody.value ?? null); - } - return { status: 200, body: { success: true } }; - - case "set-vs-context": - if (typeof parsedBody.key === "string") { - this.vsContextState.set(parsedBody.key, parsedBody.value ?? null); - } - return { status: 200, body: { success: true } }; - - case "git-origins": { - const dirs = Array.isArray(parsedBody.dirs) - ? parsedBody.dirs.filter((value): value is string => typeof value === "string") - : []; - const origins = dirs.map((dir) => { - const normalized = dir.replace(/[\\/]+$/, ""); - return { - dir: normalized, - root: normalized, - originUrl: null, - }; - }); - return { status: 200, body: { origins } }; - } - - case "git-merge-base": - return { status: 200, body: { mergeBaseSha: null } }; - - case "openai-api-key": - return { status: 200, body: { value: null } }; - - case "pick-files": - return { status: 200, body: { files: [] } }; - - case "read-file-binary": - return { status: 200, body: { contentsBase64: null } }; - - case "recommended-skills": - return { status: 200, body: { skills: [], error: null } }; - - case "generate-thread-title": { - const prompt = typeof parsedBody.prompt === "string" ? parsedBody.prompt.trim() : ""; - const title = prompt.length > 80 ? `${prompt.slice(0, 80).trimEnd()}...` : prompt; - return { status: 200, body: { title } }; - } - - case "add-workspace-root-option": { - const root = this.normalizeRoot(parsedBody.root); - const setActive = parsedBody.setActive === true; - const label = typeof parsedBody.label === "string" ? parsedBody.label.trim() : ""; - - if (!root) { - return { status: 200, body: { success: false } }; - } - - if (label.length > 0) { - this.workspaceRootOptions.labels[root] = label; - } - - const existingRoots = this.getWorkspaceRoots(); - const roots = existingRoots.includes(root) ? existingRoots : [root, ...existingRoots]; - const activeRoot = setActive ? root : this.activeWorkspaceRoot; - this.applyWorkspaceRoots(roots, activeRoot, true); - this.persistLiteEditorProjectRoot(activeRoot ?? root); - - return { status: 200, body: { success: true } }; - } - - case "set-thread-pinned": - case "set-pinned-threads-order": - case "automation-run-archive": - case "cancel-trace-recording-start": - case "confirm-trace-recording-start": - case "feedback-create-sentry-issue": - case "prepare-worktree-snapshot": - case "submit-trace-recording-details": - case "upload-worktree-snapshot": - case "git-create-branch": - return { status: 200, body: { success: true } }; - - default: - console.log("[codex-bridge] unknown vscode://codex/ endpoint:", path); - return { status: 404, error: `Unsupported internal endpoint: ${path}` }; - } - } - - // --------------------------------------------------------------------------- - // Fetch bridge (vscode://codex/* internal URLs + real HTTP) - // --------------------------------------------------------------------------- - - private async handleFetch(wc: WebContents, message: any): Promise { - const requestId = message.requestId; - const url: string = message.url; - const method: string = message.method || "GET"; - const headers: Record = message.headers || {}; - const body: string | undefined = message.body || undefined; - - if (url.startsWith("vscode://")) { - const path = this.getInternalPath(url); - - if (path === "ipc-request" && body) { - let parsedBody: any; - try { - parsedBody = JSON.parse(body); - } catch { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: 400, - error: "invalid ipc-request body", - }); - return; - } - - if (typeof parsedBody?.method !== "string") { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: 400, - error: "invalid ipc-request method", - }); - return; - } - - try { - const result = await this.sendRpcRequest(parsedBody.method, parsedBody.params); - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "success", - status: 200, - headers: { "content-type": "application/json" }, - bodyJsonString: JSON.stringify(result), - }); - } catch (error) { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: 502, - error: error instanceof Error ? error.message : String(error), - }); - } - return; - } - - if (path === "ipc-request" && !body) { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: 400, - error: "missing ipc-request body", - }); - return; - } - - if (path === "account-info") { - let result: any = null; - try { - const rpcResult = await this.sendRpcRequest("account/read", {}); - result = rpcResult?.account ?? null; - } catch { - // Keep null fallback for account-info. - } - - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "success", - status: 200, - headers: { "content-type": "application/json" }, - bodyJsonString: JSON.stringify(result), - }); - return; - } - - const internalResponse = this.getInternalUrlResponse(url, body); - if (internalResponse.error) { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: internalResponse.status, - error: internalResponse.error, - }); - return; - } - - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "success", - status: internalResponse.status, - headers: { "content-type": "application/json" }, - bodyJsonString: JSON.stringify(internalResponse.body ?? null), - }); - return; - } - - try { - const response = await fetch(url, { - method, - headers, - body: body || undefined, - }); - - const responseBody = await response.text(); - - if (response.ok) { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "success", - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - bodyJsonString: responseBody, - }); - } else { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: response.status, - error: responseBody, - }); - } - } catch (err) { - this.sendToWebview(wc, { - type: "fetch-response", - requestId, - responseType: "error", - status: 0, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - private async handleFetchStream(wc: WebContents, message: any): Promise { - const requestId = message.requestId; - const url: string = message.url; - const method: string = message.method || "GET"; - const headers: Record = message.headers || {}; - const body: string | undefined = message.body || undefined; - - if (url.startsWith("vscode://")) { - const path = this.getInternalPath(url); - - if (path === "ipc-request" && body) { - let parsedBody: any; - try { - parsedBody = JSON.parse(body); - } catch { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - status: 400, - error: "invalid ipc-request body", - }); - return; - } - - if (typeof parsedBody?.method !== "string") { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - status: 400, - error: "invalid ipc-request method", - }); - return; - } - - try { - const result = await this.sendRpcRequest(parsedBody.method, parsedBody.params); - this.sendToWebview(wc, { - type: "fetch-stream-event", - requestId, - status: 200, - headers: { "content-type": "application/json" }, - data: JSON.stringify(result), - }); - this.sendToWebview(wc, { type: "fetch-stream-complete", requestId }); - } catch (error) { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - status: 502, - error: error instanceof Error ? error.message : String(error), - }); - } - return; - } - - if (path === "ipc-request" && !body) { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - status: 400, - error: "missing ipc-request body", - }); - return; - } - - if (path === "account-info") { - let result: any = null; - try { - const rpcResult = await this.sendRpcRequest("account/read", {}); - result = rpcResult?.account ?? null; - } catch { - // Keep null fallback for account-info. - } - - this.sendToWebview(wc, { - type: "fetch-stream-event", - requestId, - status: 200, - headers: { "content-type": "application/json" }, - data: JSON.stringify(result), - }); - this.sendToWebview(wc, { type: "fetch-stream-complete", requestId }); - return; - } - - const internalResponse = this.getInternalUrlResponse(url, body); - if (internalResponse.error) { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - status: internalResponse.status, - error: internalResponse.error, - }); - return; - } - - this.sendToWebview(wc, { - type: "fetch-stream-event", - requestId, - status: internalResponse.status, - headers: { "content-type": "application/json" }, - data: JSON.stringify(internalResponse.body ?? null), - }); - this.sendToWebview(wc, { type: "fetch-stream-complete", requestId }); - return; - } - - try { - const response = await fetch(url, { - method, - headers, - body: body || undefined, - }); - - this.sendToWebview(wc, { - type: "fetch-stream-event", - requestId, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - }); - - if (response.body) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - this.sendToWebview(wc, { - type: "fetch-stream-event", - requestId, - data: decoder.decode(value, { stream: true }), - }); - } - } - - this.sendToWebview(wc, { - type: "fetch-stream-complete", - requestId, - }); - } catch (err) { - this.sendToWebview(wc, { - type: "fetch-stream-error", - requestId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // --------------------------------------------------------------------------- - // Workspace root picker (Electron dialog) - // --------------------------------------------------------------------------- - - private async handlePickWorkspaceRoot(sessionId: string, wc: WebContents): Promise { - const mainWindow = this.codexManager.getMainWindow(sessionId); - if (!mainWindow) return; - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openDirectory"], - }); - - if (!result.canceled && result.filePaths.length > 0) { - const root = result.filePaths[0]; - this.setProjectRoot(root); - this.sendToWebview(wc, { type: "workspace-root-option-picked", root }); - } - } - - private hydrateWorkspaceState(): void { - if (this.workspaceStateHydrated) return; - this.workspaceStateHydrated = true; - - const persistedRoot = this.readLiteEditorProjectRoot(); - if (!persistedRoot) return; - - this.applyWorkspaceRoots([persistedRoot], persistedRoot, false); - } - - private getWorkspaceRoots(): string[] { - const roots = this.normalizeRoots(this.workspaceRootOptions.roots); - if (this.activeWorkspaceRoot && !roots.includes(this.activeWorkspaceRoot)) { - roots.unshift(this.activeWorkspaceRoot); - } - return roots; - } - - private applyWorkspaceRoots( - roots: string[], - activeRoot: string | null | undefined, - broadcast: boolean, - ): void { - const normalizedRoots = this.normalizeRoots(roots); - const normalizedActive = - activeRoot === undefined - ? this.normalizeRoot(this.activeWorkspaceRoot) - : this.normalizeRoot(activeRoot); - - if (normalizedActive && !normalizedRoots.includes(normalizedActive)) { - normalizedRoots.unshift(normalizedActive); - } - - this.workspaceRootOptions.roots = normalizedRoots; - this.activeWorkspaceRoot = normalizedActive; - - const labels: Record = {}; - for (const root of normalizedRoots) { - labels[root] = this.workspaceRootOptions.labels[root] ?? this.getRootLabel(root); - } - this.workspaceRootOptions.labels = { ...this.workspaceRootOptions.labels, ...labels }; - - if (broadcast) { - this.broadcastToWebviews({ type: "workspace-root-options-updated" }); - } - } - - private normalizeRoots(roots: string[]): string[] { - const seen = new Set(); - const normalized: string[] = []; - for (const root of roots) { - const value = this.normalizeRoot(root); - if (!value || seen.has(value)) continue; - seen.add(value); - normalized.push(value); - } - return normalized; - } - - private normalizeRoot(root: unknown): string | null { - if (typeof root !== "string") return null; - const trimmed = root.trim(); - if (!trimmed || trimmed === "/") return null; - return trimmed; - } - - private getRootLabel(root: string): string { - const trimmed = root.replace(/[\\/]+$/, ""); - return basename(trimmed) || trimmed; - } - - private buildOpenTargetsResponse(parsedBody: Record): { - preferredTarget: string | null; - targets: Array<{ - id: string; - label: string; - description: string; - default: boolean; - available: boolean; - }>; - availableTargets: string[]; - } { - const cwd = this.normalizeRoot(parsedBody.cwd); - const roots = this.getWorkspaceRoots(); - const targetIds = cwd && !roots.includes(cwd) ? [cwd, ...roots] : roots; - - let preferredTarget = this.activeWorkspaceRoot; - if (!preferredTarget && cwd && targetIds.includes(cwd)) preferredTarget = cwd; - if (!preferredTarget && targetIds.length > 0) preferredTarget = targetIds[0]; - - const targets = targetIds.map((id) => ({ - id, - label: this.workspaceRootOptions.labels[id] ?? this.getRootLabel(id), - description: id, - default: preferredTarget === id, - available: true, - })); - - return { - preferredTarget: preferredTarget ?? null, - targets, - availableTargets: targetIds, - }; - } - - private readLiteEditorProjectRoot(): string | null { - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - if (!homeDir) return null; - - const workspacePath = join(homeDir, ".liteeditor", "workspace.json"); - if (!existsSync(workspacePath)) return null; - - try { - const parsed = JSON.parse(readFileSync(workspacePath, "utf-8")) as { projectRoot?: unknown }; - return this.normalizeRoot(parsed.projectRoot); - } catch { - return null; - } - } - - private persistLiteEditorProjectRoot(root: string): void { - const homeDir = process.env.USERPROFILE || process.env.HOME || ""; - if (!homeDir) return; - - const liteeditorDir = join(homeDir, ".liteeditor"); - const workspacePath = join(liteeditorDir, "workspace.json"); - - try { - mkdirSync(liteeditorDir, { recursive: true }); - let existing: Record = {}; - if (existsSync(workspacePath)) { - try { - const parsed = JSON.parse(readFileSync(workspacePath, "utf-8")) as Record< - string, - unknown - >; - if (parsed && typeof parsed === "object") { - existing = parsed; - } - } catch { - // Keep default empty object. - } - } - - existing.projectRoot = root; - - writeFileSync(workspacePath, JSON.stringify(existing), "utf-8"); - } catch { - // Persistence failure should not block Codex runtime. - } - } - - // --------------------------------------------------------------------------- - // Shared objects - // --------------------------------------------------------------------------- - - private handleSharedObjectSubscribe(sessionId: string, message: any): void { - const key = message.key; - if (!key) return; - - if (!this.sharedObjectSubscribers.has(key)) { - this.sharedObjectSubscribers.set(key, new Set()); - } - this.sharedObjectSubscribers.get(key)?.add(sessionId); - - const wc = this.codexManager.getWebContents(sessionId); - if (wc && key in this.sharedObjects) { - this.sendToWebview(wc, { - type: "shared-object-updated", - key, - value: this.sharedObjects[key], - }); - } - } - - private handleSharedObjectUnsubscribe(sessionId: string, message: any): void { - const key = message.key; - if (!key) return; - - const subscribers = this.sharedObjectSubscribers.get(key); - if (subscribers) { - subscribers.delete(sessionId); - if (subscribers.size === 0) { - this.sharedObjectSubscribers.delete(key); - } - } - } - - private handleSharedObjectSet(wc: WebContents, message: any): void { - const key = message.key; - const value = message.value; - if (!key) return; - - this.sharedObjects[key] = value; - - const subscribers = this.sharedObjectSubscribers.get(key); - if (subscribers) { - for (const sessionId of subscribers) { - const subWc = this.codexManager.getWebContents(sessionId); - if (subWc) { - this.sendToWebview(subWc, { - type: "shared-object-updated", - key, - value, - }); - } - } - } - } - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - private sendToWebview(wc: WebContents, message: any): void { - try { - wc.send("codex:host-message", message); - } catch { - // webContents may be destroyed - } - } - - removeSession(sessionId: string): void { - this.connectedWebContents.delete(sessionId); - for (const [key, subscribers] of Array.from(this.sharedObjectSubscribers.entries())) { - subscribers.delete(sessionId); - if (subscribers.size === 0) { - this.sharedObjectSubscribers.delete(key); - } - } - } - - shutdown(): void { - if (this.proc) { - console.log("[codex-bridge] Killing codex.exe process"); - this.proc.kill(); - this.proc = null; - } - - this.serverInitialized = false; - if (this.initializeResolve) { - this.initializeResolve(); - this.initializeResolve = null; - } - this.initializePromise = null; - - this.pendingRequests.clear(); - for (const [, cb] of this.rpcCallbacks) { - cb.reject(new Error("shutdown")); - } - this.rpcCallbacks.clear(); - - this.lineBuffer = ""; - this.connectedWebContents.clear(); - this.sharedObjectSubscribers.clear(); - } -} diff --git a/apps/desktop/src/src/liteeditor/services/codex-manager.ts b/apps/desktop/src/src/liteeditor/services/codex-manager.ts deleted file mode 100644 index 2770e9c..0000000 --- a/apps/desktop/src/src/liteeditor/services/codex-manager.ts +++ /dev/null @@ -1,173 +0,0 @@ -// @ts-nocheck -import { WebContentsView, BrowserWindow, app } from "electron"; -import { join } from "path"; -import { writeFileSync, mkdirSync, readFileSync } from "fs"; -import { type NativeViewBounds, toContentBounds } from "./native-view-bounds"; -import { buildWebviewThemeCss } from "./webview-theme"; -import { integrationsManager } from "./integrations-manager"; - -let counter = 0; - -interface CodexSession { - id: string; - view: WebContentsView; - mainWindow: BrowserWindow; - hidden: boolean; -} - -export class CodexManager { - private sessions = new Map(); - private wrapperHtmlPathByExtension = new Map(); - - findCodexExtension(): string | null { - return integrationsManager.resolve("codex")?.path ?? null; - } - - private getWrapperHtmlPath(extPath: string): string { - const existing = this.wrapperHtmlPathByExtension.get(extPath); - if (existing) return existing; - - const tmpDir = join(app.getPath("temp"), "liteeditor-codex"); - try { - mkdirSync(tmpDir, { recursive: true }); - } catch { - /* exists */ - } - - const srcHtml = readFileSync(join(extPath, "webview", "index.html"), "utf-8"); - const webviewDir = join(extPath, "webview").replace(/\\/g, "/"); - - let html = srcHtml - .replace('', '') - .replace("", ``) - .replace( - "", - ``, - ) - .replace(/\s+crossorigin/g, ""); - - const cssVars = buildWebviewThemeCss(); - - // Guard: the bundle unconditionally overwrites data-codex-window-type and data-window-type - // to "extension" at module init. This MutationObserver reverts them to "electron" immediately. - const windowTypeGuard = ``; - - html = html.replace("", `${windowTypeGuard}\n${cssVars}\n`); - - const htmlPath = join(tmpDir, `codex-webview-${this.toFileSafe(extPath)}.html`); - writeFileSync(htmlPath, html, "utf-8"); - this.wrapperHtmlPathByExtension.set(extPath, htmlPath); - return htmlPath; - } - - private toFileSafe(value: string): string { - return value.replace(/[\\/:*?"<>|]/g, "_"); - } - - createSession(mainWindow: BrowserWindow): string { - const id = `codex-${++counter}-${Date.now()}`; - - const preloadPath = join(__dirname, "liteeditor-codex-preload.js"); - - const view = new WebContentsView({ - webPreferences: { - preload: preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - view.setBounds({ x: 0, y: 0, width: 0, height: 0 }); - mainWindow.contentView.addChildView(view); - - const session: CodexSession = { - id, - view, - mainWindow, - hidden: false, - }; - this.sessions.set(id, session); - - view.webContents.on("did-fail-load", (_e, errorCode, errorDescription, validatedURL) => { - console.error("[codex] did-fail-load:", errorCode, errorDescription, validatedURL); - }); - - view.webContents.on("did-finish-load", () => { - view.webContents.send("codex:set-session-id", id); - }); - - const extPath = this.findCodexExtension(); - if (extPath) { - const htmlPath = this.getWrapperHtmlPath(extPath); - view.webContents.loadFile(htmlPath); - } else { - view.webContents.loadURL( - 'data:text/html,

Codex Extension Not Found

Open Settings -> Integrations to install OpenAI Codex.

', - ); - } - - return id; - } - - setBounds(sessionId: string, bounds: NativeViewBounds): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.view.setBounds(toContentBounds(session.mainWindow, bounds)); - } - - showView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || !session.hidden) return; - session.hidden = false; - try { - session.mainWindow.contentView.addChildView(session.view); - } catch { - /* window may be closed */ - } - } - - hideView(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session || session.hidden) return; - session.hidden = true; - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may be closed */ - } - } - - getWebContents(sessionId: string) { - const session = this.sessions.get(sessionId); - if (!session) return undefined; - return session.view.webContents; - } - - getMainWindow(sessionId: string): BrowserWindow | undefined { - const session = this.sessions.get(sessionId); - return session?.mainWindow; - } - - destroySession(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session) return; - this.sessions.delete(sessionId); - try { - session.mainWindow.contentView.removeChildView(session.view); - } catch { - /* window may already be closed */ - } - try { - session.view.webContents.close(); - } catch { - /* already destroyed */ - } - } - - removeAll(): void { - for (const sessionId of Array.from(this.sessions.keys())) { - this.destroySession(sessionId); - } - } -} diff --git a/apps/desktop/src/src/liteeditor/services/dev-server-manager.ts b/apps/desktop/src/src/liteeditor/services/dev-server-manager.ts deleted file mode 100644 index c07ff98..0000000 --- a/apps/desktop/src/src/liteeditor/services/dev-server-manager.ts +++ /dev/null @@ -1,162 +0,0 @@ -// @ts-nocheck -import * as pty from "node-pty"; -import { platform } from "os"; - -let counter = 0; - -export interface RunningScript { - sessionId: string; - projectId: string; - scriptName: string; - pid: number; - detectedPort: number | null; -} - -export interface ScriptStatusEvent { - type: "started" | "stopped" | "crashed"; - sessionId: string; - projectId: string; - scriptName: string; - exitCode?: number; - detectedPort?: number | null; -} - -// Matches common dev server port output patterns: -// "localhost:3000", ":3000", "port 3000", "Port 3000", "http://localhost:3000" -const PORT_PATTERNS = [ - /https?:\/\/localhost:(\d{2,5})/, - /https?:\/\/127\.0\.0\.1:(\d{2,5})/, - /https?:\/\/0\.0\.0\.0:(\d{2,5})/, - /localhost:(\d{2,5})/, - /127\.0\.0\.1:(\d{2,5})/, - /0\.0\.0\.0:(\d{2,5})/, - /[Pp]ort\s+(\d{2,5})/, - /:(\d{2,5})\b/, -]; - -interface ScriptSession { - sessionId: string; - projectId: string; - scriptName: string; - process: pty.IPty; - detectedPort: number | null; -} - -export class DevServerManager { - private sessions = new Map(); - private listeners: Array<(event: ScriptStatusEvent) => void> = []; - - startScript(projectId: string, scriptName: string, cwd: string): string { - const sessionId = `script-${++counter}-${Date.now()}`; - - const shell = platform() === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/bash"; - - const proc = pty.spawn(shell, [], { - name: "xterm-256color", - cols: 120, - rows: 30, - cwd, - env: process.env as Record, - }); - - const session: ScriptSession = { - sessionId, - projectId, - scriptName, - process: proc, - detectedPort: null, - }; - - this.sessions.set(sessionId, session); - - // Write the script command to the shell - const cmd = platform() === "win32" ? `${scriptName}\r` : `${scriptName}\n`; - proc.write(cmd); - - // Parse PTY output for port detection - proc.onData((data) => { - if (session.detectedPort !== null) return; // Already found a port - - for (const pattern of PORT_PATTERNS) { - const match = data.match(pattern); - if (match) { - const port = parseInt(match[1], 10); - if (port >= 1 && port <= 65535) { - session.detectedPort = port; - break; - } - } - } - }); - - proc.onExit(({ exitCode }) => { - const stopped = this.sessions.get(sessionId); - if (stopped) { - this.sessions.delete(sessionId); - this.emit({ - type: exitCode === 0 ? "stopped" : "crashed", - sessionId, - projectId: stopped.projectId, - scriptName: stopped.scriptName, - exitCode, - detectedPort: stopped.detectedPort, - }); - } - }); - - this.emit({ - type: "started", - sessionId, - projectId, - scriptName, - }); - - return sessionId; - } - - stopScript(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (session) { - session.process.kill(); - this.sessions.delete(sessionId); - this.emit({ - type: "stopped", - sessionId, - projectId: session.projectId, - scriptName: session.scriptName, - detectedPort: session.detectedPort, - }); - } - } - - getRunningScripts(): RunningScript[] { - return Array.from(this.sessions.values()).map((session) => ({ - sessionId: session.sessionId, - projectId: session.projectId, - scriptName: session.scriptName, - pid: session.process.pid, - detectedPort: session.detectedPort, - })); - } - - onStatusChange(callback: (event: ScriptStatusEvent) => void): void { - this.listeners.push(callback); - } - - killAll(): void { - this.sessions.forEach((session) => { - session.process.kill(); - }); - this.sessions.clear(); - } - - private emit(event: ScriptStatusEvent): void { - for (const listener of this.listeners) { - try { - listener(event); - } catch { - // Listener errors should not break the manager - } - } - } -} diff --git a/apps/desktop/src/src/liteeditor/services/dom-helper.ts b/apps/desktop/src/src/liteeditor/services/dom-helper.ts deleted file mode 100644 index a53604a..0000000 --- a/apps/desktop/src/src/liteeditor/services/dom-helper.ts +++ /dev/null @@ -1,157 +0,0 @@ -// @ts-nocheck -/** - * Injectable JavaScript strings for agent DOM interaction. - * These are executed inside the webview via webContents.executeJavaScript(). - */ - -/** IIFE that indexes all interactive elements visible in the viewport */ -export const DOM_INDEX_SCRIPT = `(function() { - const INTERACTIVE_SELECTORS = [ - 'a[href]', 'button', 'input', 'textarea', 'select', - '[role="button"]', '[role="link"]', '[role="checkbox"]', - '[role="radio"]', '[role="tab"]', '[role="menuitem"]', - '[tabindex]', '[contenteditable="true"]', - 'details > summary' - ]; - - function isVisible(el) { - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; - const rect = el.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) return false; - return true; - } - - function isInViewport(el) { - const rect = el.getBoundingClientRect(); - return ( - rect.bottom > 0 && - rect.right > 0 && - rect.top < window.innerHeight && - rect.left < window.innerWidth - ); - } - - // Remove old indices - document.querySelectorAll('[data-agent-idx]').forEach(el => el.removeAttribute('data-agent-idx')); - - const allElements = document.querySelectorAll(INTERACTIVE_SELECTORS.join(', ')); - const elements = []; - let idx = 0; - - for (const el of allElements) { - if (!isVisible(el) || !isInViewport(el)) continue; - - el.setAttribute('data-agent-idx', String(idx)); - const rect = el.getBoundingClientRect(); - const info = { - index: idx, - tag: el.tagName.toLowerCase(), - type: el.getAttribute('type') || undefined, - text: (el.textContent || '').trim().substring(0, 200), - role: el.getAttribute('role') || undefined, - bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - value: el.value !== undefined ? String(el.value).substring(0, 200) : undefined, - placeholder: el.getAttribute('placeholder') || undefined, - href: el.getAttribute('href') || undefined, - checked: el.checked !== undefined ? el.checked : undefined, - ariaLabel: el.getAttribute('aria-label') || undefined, - name: el.getAttribute('name') || undefined - }; - - // Clean undefined values - Object.keys(info).forEach(k => { if (info[k] === undefined) delete info[k]; }); - elements.push(info); - idx++; - } - - // Get visible text (truncated) - const bodyText = document.body ? document.body.innerText : ''; - const visibleText = bodyText.substring(0, 5000); - - return { - url: window.location.href, - title: document.title, - elements: elements, - visibleText: visibleText - }; -})()`; - -/** Click the element with the given data-agent-idx */ -export function getClickScript(index: number): string { - return `(function() { - const el = document.querySelector('[data-agent-idx="${index}"]'); - if (!el) return { success: false, error: 'Element not found with index ${index}' }; - el.scrollIntoView({ behavior: 'instant', block: 'center' }); - el.click(); - return { success: true }; - })()`; -} - -/** Type text into an element. If index is provided, focuses that element first. */ -export function getTypeScript(text: string, index?: number): string { - const escaped = text - .replace(/\\/g, "\\\\") - .replace(/'/g, "\\'") - .replace(/`/g, "\\`") - .replace(/\$/g, "\\$") - .replace(/\n/g, "\\n"); - if (index !== undefined) { - return `(function() { - const el = document.querySelector('[data-agent-idx="${index}"]'); - if (!el) return { success: false, error: 'Element not found with index ${index}' }; - el.scrollIntoView({ behavior: 'instant', block: 'center' }); - el.focus(); - if ('value' in el) { - el.value = '${escaped}'; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - } else if (el.isContentEditable) { - el.textContent = '${escaped}'; - el.dispatchEvent(new Event('input', { bubbles: true })); - } - return { success: true }; - })()`; - } - return `(function() { - const el = document.activeElement; - if (!el) return { success: false, error: 'No focused element' }; - if ('value' in el) { - el.value = '${escaped}'; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - } else if (el.isContentEditable) { - el.textContent = '${escaped}'; - el.dispatchEvent(new Event('input', { bubbles: true })); - } - return { success: true }; - })()`; -} - -/** Scroll the page in a direction by a given amount (pixels) */ -export function getScrollScript( - direction: "up" | "down" | "left" | "right", - amount: number, -): string { - const xMap: Record = { left: -1, right: 1, up: 0, down: 0 }; - const yMap: Record = { up: -1, down: 1, left: 0, right: 0 }; - const x = xMap[direction] * amount; - const y = yMap[direction] * amount; - return `(function() { - window.scrollBy(${x}, ${y}); - return { success: true, scrollX: window.scrollX, scrollY: window.scrollY }; - })()`; -} - -/** Select an option in a onEnvModeChange(value as EnvMode)} - items={envModeItems} - > - - {effectiveEnvMode === "worktree" ? ( - - ) : ( - - )} - - - - - - - Local - - - - - - New worktree - - - - - )} - - - - ); -} diff --git a/apps/web/src/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/src/components/BranchToolbarBranchSelector.tsx deleted file mode 100644 index 9446279..0000000 --- a/apps/web/src/src/components/BranchToolbarBranchSelector.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import type { GitBranch } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronDownIcon } from "lucide-react"; -import { - type CSSProperties, - useCallback, - useDeferredValue, - useEffect, - useMemo, - useOptimistic, - useRef, - useState, - useTransition, -} from "react"; - -import { - gitBranchesQueryOptions, - gitQueryKeys, - gitStatusQueryOptions, - invalidateGitQueries, -} from "../lib/gitReactQuery"; -import { readNativeApi } from "../nativeApi"; -import { parsePullRequestReference } from "../pullRequestReference"; -import { - dedupeRemoteBranchesWithLocalMatches, - deriveLocalBranchNameFromRemoteRef, - EnvMode, - resolveBranchSelectionTarget, - resolveBranchToolbarValue, -} from "./BranchToolbar.logic"; -import { Button } from "./ui/button"; -import { - Combobox, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, - ComboboxPopup, - ComboboxTrigger, -} from "./ui/combobox"; -import { toastManager } from "./ui/toast"; - -interface BranchToolbarBranchSelectorProps { - activeProjectCwd: string; - activeThreadBranch: string | null; - activeWorktreePath: string | null; - branchCwd: string | null; - effectiveEnvMode: EnvMode; - envLocked: boolean; - onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; - onCheckoutPullRequestRequest?: (reference: string) => void; - onComposerFocusRequest?: () => void; -} - -function toBranchActionErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "An error occurred."; -} - -function getBranchTriggerLabel(input: { - activeWorktreePath: string | null; - effectiveEnvMode: EnvMode; - resolvedActiveBranch: string | null; -}): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; - if (!resolvedActiveBranch) { - return "Select branch"; - } - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { - return `From ${resolvedActiveBranch}`; - } - return resolvedActiveBranch; -} - -export function BranchToolbarBranchSelector({ - activeProjectCwd, - activeThreadBranch, - activeWorktreePath, - branchCwd, - effectiveEnvMode, - envLocked, - onSetThreadBranch, - onCheckoutPullRequestRequest, - onComposerFocusRequest, -}: BranchToolbarBranchSelectorProps) { - const queryClient = useQueryClient(); - const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); - const [branchQuery, setBranchQuery] = useState(""); - const deferredBranchQuery = useDeferredValue(branchQuery); - - const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); - const branches = useMemo( - () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), - [branchesQuery.data?.branches], - ); - const currentGitBranch = - branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; - const canonicalActiveBranch = resolveBranchToolbarValue({ - envMode: effectiveEnvMode, - activeWorktreePath, - activeThreadBranch, - currentGitBranch, - }); - const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); - const branchByName = useMemo( - () => new Map(branches.map((branch) => [branch.name, branch] as const)), - [branches], - ); - const trimmedBranchQuery = branchQuery.trim(); - const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); - const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); - const prReference = parsePullRequestReference(trimmedBranchQuery); - const isSelectingWorktreeBase = - effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; - const checkoutPullRequestItemValue = - prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; - const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; - const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); - const createBranchItemValue = canCreateBranch - ? `__create_new_branch__:${trimmedBranchQuery}` - : null; - const branchPickerItems = useMemo(() => { - const items = [...branchNames]; - if (createBranchItemValue && !hasExactBranchMatch) { - items.push(createBranchItemValue); - } - if (checkoutPullRequestItemValue) { - items.unshift(checkoutPullRequestItemValue); - } - return items; - }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]); - const filteredBranchPickerItems = useMemo( - () => - normalizedDeferredBranchQuery.length === 0 - ? branchPickerItems - : branchPickerItems.filter((itemValue) => { - if (createBranchItemValue && itemValue === createBranchItemValue) return true; - return itemValue.toLowerCase().includes(normalizedDeferredBranchQuery); - }), - [branchPickerItems, createBranchItemValue, normalizedDeferredBranchQuery], - ); - const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( - canonicalActiveBranch, - (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, - ); - const [isBranchActionPending, startBranchActionTransition] = useTransition(); - const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; - - const runBranchAction = (action: () => Promise) => { - startBranchActionTransition(async () => { - await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); - }); - }; - - const selectBranch = (branch: GitBranch) => { - const api = readNativeApi(); - if (!api || !branchCwd || isBranchActionPending) return; - - // In new-worktree mode, selecting a branch sets the base branch. - if (isSelectingWorktreeBase) { - onSetThreadBranch(branch.name, null); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - const selectionTarget = resolveBranchSelectionTarget({ - activeProjectCwd, - activeWorktreePath, - branch, - }); - - // If the branch already lives in a worktree, point the thread there. - if (selectionTarget.reuseExistingWorktree) { - onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name) - : branch.name; - - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - - runBranchAction(async () => { - setOptimisticBranch(selectedBranchName); - try { - await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); - return; - } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: branchCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } - } - - setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); - }); - }; - - const createBranch = (rawName: string) => { - const name = rawName.trim(); - const api = readNativeApi(); - if (!api || !branchCwd || !name || isBranchActionPending) return; - - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - - runBranchAction(async () => { - setOptimisticBranch(name); - - try { - await api.git.createBranch({ cwd: branchCwd, branch: name }); - try { - await api.git.checkout({ cwd: branchCwd, branch: name }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); - return; - } - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to create branch.", - description: toBranchActionErrorMessage(error), - }); - return; - } - - setOptimisticBranch(name); - onSetThreadBranch(name, activeWorktreePath); - setBranchQuery(""); - }); - }; - - useEffect(() => { - if ( - effectiveEnvMode !== "worktree" || - activeWorktreePath || - activeThreadBranch || - !currentGitBranch - ) { - return; - } - onSetThreadBranch(currentGitBranch, null); - }, [ - activeThreadBranch, - activeWorktreePath, - currentGitBranch, - effectiveEnvMode, - onSetThreadBranch, - ]); - - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; - } - void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), - }); - }, - [branchCwd, queryClient], - ); - - const branchListScrollElementRef = useRef(null); - const branchListVirtualizer = useVirtualizer({ - count: filteredBranchPickerItems.length, - estimateSize: (index) => - filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, - getScrollElement: () => branchListScrollElementRef.current, - overscan: 12, - enabled: isBranchMenuOpen && shouldVirtualizeBranchList, - initialRect: { - height: 224, - width: 0, - }, - }); - const virtualBranchRows = branchListVirtualizer.getVirtualItems(); - const setBranchListRef = useCallback( - (element: HTMLDivElement | null) => { - branchListScrollElementRef.current = - (element?.parentElement as HTMLDivElement | null) ?? null; - if (element) { - branchListVirtualizer.measure(); - } - }, - [branchListVirtualizer], - ); - - useEffect(() => { - if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; - queueMicrotask(() => { - branchListVirtualizer.measure(); - }); - }, [ - branchListVirtualizer, - filteredBranchPickerItems.length, - isBranchMenuOpen, - shouldVirtualizeBranchList, - ]); - - const triggerLabel = getBranchTriggerLabel({ - activeWorktreePath, - effectiveEnvMode, - resolvedActiveBranch, - }); - - function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { - if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { - return ( - { - if (!prReference || !onCheckoutPullRequestRequest) { - return; - } - setIsBranchMenuOpen(false); - setBranchQuery(""); - onComposerFocusRequest?.(); - onCheckoutPullRequestRequest(prReference); - }} - > -
- Checkout Pull Request - {prReference} -
-
- ); - } - if (createBranchItemValue && itemValue === createBranchItemValue) { - return ( - createBranch(trimmedBranchQuery)} - > - Create new branch "{trimmedBranchQuery}" - - ); - } - - const branch = branchByName.get(itemValue); - if (!branch) return null; - - const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; - const badge = branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : branch.isRemote - ? "remote" - : branch.isDefault - ? "default" - : null; - return ( - selectBranch(branch)} - > -
- {itemValue} - {badge && {badge}} -
-
- ); - } - - return ( - { - if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); - }} - onOpenChange={handleOpenChange} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className="text-muted-foreground/70 hover:text-foreground/80" - disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} - > - {triggerLabel} - - - -
- setBranchQuery(event.target.value)} - /> -
- No branches found. - - - {shouldVirtualizeBranchList ? ( -
- {virtualBranchRows.map((virtualRow) => { - const itemValue = filteredBranchPickerItems[virtualRow.index]; - if (!itemValue) return null; - return renderPickerItem(itemValue, virtualRow.index, { - position: "absolute", - top: 0, - left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - }); - })} -
- ) : ( - filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) - )} -
-
-
- ); -} diff --git a/apps/web/src/src/components/ChatMarkdown.tsx b/apps/web/src/src/components/ChatMarkdown.tsx deleted file mode 100644 index 9663d15..0000000 --- a/apps/web/src/src/components/ChatMarkdown.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { - Children, - Suspense, - isValidElement, - use, - useCallback, - memo, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import type { Components } from "react-markdown"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { openInPreferredEditor } from "../editorPreferences"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; -import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; -import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget } from "../markdown-links"; -import { readNativeApi } from "../nativeApi"; - -class CodeHighlightErrorBoundary extends React.Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - -interface ChatMarkdownProps { - text: string; - cwd: string | undefined; - isStreaming?: boolean; -} - -const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; -const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; -const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; -const highlightedCodeCache = new LRUCache( - MAX_HIGHLIGHT_CACHE_ENTRIES, - MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, -); -const highlighterPromiseCache = new Map>(); - -function extractFenceLanguage(className: string | undefined): string { - const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; -} - -function nodeToPlainText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - if (Array.isArray(node)) { - return node.map((child) => nodeToPlainText(child)).join(""); - } - if (isValidElement<{ children?: ReactNode }>(node)) { - return nodeToPlainText(node.props.children); - } - return ""; -} - -function extractCodeBlock( - children: ReactNode, -): { className: string | undefined; code: string } | null { - const childNodes = Children.toArray(children); - if (childNodes.length !== 1) { - return null; - } - - const onlyChild = childNodes[0]; - if ( - !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || - onlyChild.type !== "code" - ) { - return null; - } - - return { - className: onlyChild.props.className, - code: nodeToPlainText(onlyChild.props.children), - }; -} - -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; -} - -function estimateHighlightedSize(html: string, code: string): number { - return Math.max(html.length * 2, code.length * 3); -} - -function getHighlighterPromise(language: string): Promise { - const cached = highlighterPromiseCache.get(language); - if (cached) return cached; - - const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], - langs: [language as SupportedLanguages], - preferredHighlighter: "shiki-js", - }).catch((err) => { - highlighterPromiseCache.delete(language); - if (language === "text") { - // "text" itself failed — Shiki cannot initialize at all, surface the error - throw err; - } - // Language not supported by Shiki — fall back to "text" - return getHighlighterPromise("text"); - }); - highlighterPromiseCache.set(language, promise); - return promise; -} - -function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); - const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch(() => undefined); - }, [code]); - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - return ( -
- - {children} -
- ); -} - -interface SuspenseShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; -} - -function SuspenseShikiCodeBlock({ - className, - code, - themeName, - isStreaming, -}: SuspenseShikiCodeBlockProps) { - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - const highlighter = use(getHighlighterPromise(language)); - const highlightedHtml = useMemo(() => { - try { - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - // Log highlighting failures for debugging while falling back to plain text - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - // If highlighting fails for this language, render as plain text - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - }, [code, highlighter, language, themeName]); - - useEffect(() => { - if (!isStreaming) { - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlightedHtml, isStreaming]); - - return ( -
- ); -} - -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownComponents = useMemo( - () => ({ - a({ node: _node, href, ...props }) { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); - if (!targetPath) { - return ; - } - - return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readNativeApi(); - if (api) { - void openInPreferredEditor(api, targetPath); - } else { - console.warn("Native API not found. Unable to open file in editor."); - } - }} - /> - ); - }, - pre({ node: _node, children, ...props }) { - const codeBlock = extractCodeBlock(children); - if (!codeBlock) { - return
{children}
; - } - - return ( - - {children}}> - {children}}> - - - - - ); - }, - }), - [cwd, diffThemeName, isStreaming], - ); - - return ( -
- - {text} - -
- ); -} - -export default memo(ChatMarkdown); diff --git a/apps/web/src/src/components/ChatView.browser.tsx b/apps/web/src/src/components/ChatView.browser.tsx deleted file mode 100644 index 48c6277..0000000 --- a/apps/web/src/src/components/ChatView.browser.tsx +++ /dev/null @@ -1,1636 +0,0 @@ -// Production CSS is part of the behavior under test because row height depends on it. -import "../index.css"; - -import { - ORCHESTRATION_WS_METHODS, - type MessageId, - type OrchestrationReadModel, - type ProjectId, - type ServerConfig, - type ThreadId, - type WsWelcomePayload, - WS_CHANNELS, - WS_METHODS, - OrchestrationSessionStatus, -} from "@t3tools/contracts"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; -import { HttpResponse, http, ws } from "msw"; -import { setupWorker } from "msw/browser"; -import { page } from "vitest/browser"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { useComposerDraftStore } from "../composerDraftStore"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, -} from "../lib/terminalContext"; -import { isMacPlatform } from "../lib/utils"; -import { getRouter } from "../router"; -import { useStore } from "../store"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; - -const THREAD_ID = "thread-browser-test" as ThreadId; -const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const PROJECT_ID = "project-1" as ProjectId; -const NOW_ISO = "2026-03-04T12:00:00.000Z"; -const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; - -interface WsRequestEnvelope { - id: string; - body: { - _tag: string; - [key: string]: unknown; - }; -} - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: WsWelcomePayload; -} - -let fixture: TestFixture; -const wsRequests: WsRequestEnvelope["body"][] = []; -const wsLink = ws.link(/ws(s)?:\/\/.*/); - -interface ViewportSpec { - name: string; - width: number; - height: number; - textTolerancePx: number; - attachmentTolerancePx: number; -} - -const DEFAULT_VIEWPORT: ViewportSpec = { - name: "desktop", - width: 960, - height: 1_100, - textTolerancePx: 44, - attachmentTolerancePx: 56, -}; -const TEXT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, -] as const satisfies readonly ViewportSpec[]; -const ATTACHMENT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, -] as const satisfies readonly ViewportSpec[]; - -interface UserRowMeasurement { - measuredRowHeightPx: number; - timelineWidthMeasuredPx: number; - renderedInVirtualizedRegion: boolean; -} - -interface MountedChatView { - cleanup: () => Promise; - measureUserRow: (targetMessageId: MessageId) => Promise; - setViewport: (viewport: ViewportSpec) => Promise; - router: ReturnType; -} - -function isoAt(offsetSeconds: number): string { - return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); -} - -function createBaseServerConfig(): ServerConfig { - return { - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - provider: "codex", - status: "ready", - available: true, - authStatus: "authenticated", - checkedAt: NOW_ISO, - }, - ], - availableEditors: [], - }; -} - -function createUserMessage(options: { - id: MessageId; - text: string; - offsetSeconds: number; - attachments?: Array<{ - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - }>; -}) { - return { - id: options.id, - role: "user" as const, - text: options.text, - ...(options.attachments ? { attachments: options.attachments } : {}), - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { - return { - id: options.id, - role: "assistant" as const, - text: options.text, - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createTerminalContext(input: { - id: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; - text: string; -}): TerminalContextDraft { - return { - id: input.id, - threadId: THREAD_ID, - terminalId: `terminal-${input.id}`, - terminalLabel: input.terminalLabel, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - text: input.text, - createdAt: NOW_ISO, - }; -} - -function createSnapshotForTargetUser(options: { - targetMessageId: MessageId; - targetText: string; - targetAttachmentCount?: number; - sessionStatus?: OrchestrationSessionStatus; -}): OrchestrationReadModel { - const messages: Array = []; - - for (let index = 0; index < 22; index += 1) { - const isTarget = index === 3; - const userId = `msg-user-${index}` as MessageId; - const assistantId = `msg-assistant-${index}` as MessageId; - const attachments = - isTarget && (options.targetAttachmentCount ?? 0) > 0 - ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ - type: "image" as const, - id: `attachment-${attachmentIndex + 1}`, - name: `attachment-${attachmentIndex + 1}.png`, - mimeType: "image/png", - sizeBytes: 128, - })) - : undefined; - - messages.push( - createUserMessage({ - id: isTarget ? options.targetMessageId : userId, - text: isTarget ? options.targetText : `filler user message ${index}`, - offsetSeconds: messages.length * 3, - ...(attachments ? { attachments } : {}), - }), - ); - messages.push( - createAssistantMessage({ - id: assistantId, - text: `assistant filler ${index}`, - offsetSeconds: messages.length * 3, - }), - ); - } - - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModel: "gpt-5", - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: "Browser test thread", - model: "gpt-5", - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - messages, - activities: [], - proposedPlans: [], - checkpoints: [], - session: { - threadId: THREAD_ID, - status: options.sessionStatus ?? "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - updatedAt: NOW_ISO, - }; -} - -function buildFixture(snapshot: OrchestrationReadModel): TestFixture { - return { - snapshot, - serverConfig: createBaseServerConfig(), - welcome: { - cwd: "/repo/project", - projectName: "Project", - bootstrapProjectId: PROJECT_ID, - bootstrapThreadId: THREAD_ID, - }, - }; -} - -function addThreadToSnapshot( - snapshot: OrchestrationReadModel, - threadId: ThreadId, -): OrchestrationReadModel { - return { - ...snapshot, - snapshotSequence: snapshot.snapshotSequence + 1, - threads: [ - ...snapshot.threads, - { - id: threadId, - projectId: PROJECT_ID, - title: "New thread", - model: "gpt-5", - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - session: { - threadId, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - }; -} - -function createDraftOnlySnapshot(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-target" as MessageId, - targetText: "draft thread", - }); - return { - ...snapshot, - threads: [], - }; -} - -function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-plan-target" as MessageId, - targetText: "plan thread", - }); - const planMarkdown = [ - "# Ship plan mode follow-up", - "", - "- Step 1: capture the thread-open trace", - "- Step 2: identify the main-thread bottleneck", - "- Step 3: keep collapsed cards cheap", - "- Step 4: render the full markdown only on demand", - "- Step 5: preserve export and save actions", - "- Step 6: add regression coverage", - "- Step 7: verify route transitions stay responsive", - "- Step 8: confirm no server-side work changed", - "- Step 9: confirm short plans still render normally", - "- Step 10: confirm long plans stay collapsed by default", - "- Step 11: confirm preview text is still useful", - "- Step 12: confirm plan follow-up flow still works", - "- Step 13: confirm timeline virtualization still behaves", - "- Step 14: confirm theme styling still looks correct", - "- Step 15: confirm save dialog behavior is unchanged", - "- Step 16: confirm download behavior is unchanged", - "- Step 17: confirm code fences do not parse until expand", - "- Step 18: confirm preview truncation ends cleanly", - "- Step 19: confirm markdown links still open in editor after expand", - "- Step 20: confirm deep hidden detail only appears after expand", - "", - "```ts", - "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", - "```", - ].join("\n"); - - return { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - proposedPlans: [ - { - id: "plan-browser-test", - turnId: null, - planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(1_000), - updatedAt: isoAt(1_001), - }, - ], - updatedAt: isoAt(1_001), - }) - : thread, - ), - }; -} - -function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { - const tag = body._tag; - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } - if (tag === WS_METHODS.serverGetConfig) { - return fixture.serverConfig; - } - if (tag === WS_METHODS.gitListBranches) { - return { - isRepo: true, - hasOriginRemote: true, - branches: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - ], - }; - } - if (tag === WS_METHODS.gitStatus) { - return { - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } - if (tag === WS_METHODS.projectsSearchEntries) { - return { - entries: [], - truncated: false, - }; - } - if (tag === WS_METHODS.terminalOpen) { - return { - threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, - terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", - cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: NOW_ISO, - }; - } - return {}; -} - -const worker = setupWorker( - wsLink.addEventListener("connection", ({ client }) => { - client.send( - JSON.stringify({ - type: "push", - sequence: 1, - channel: WS_CHANNELS.serverWelcome, - data: fixture.welcome, - }), - ); - client.addEventListener("message", (event) => { - const rawData = event.data; - if (typeof rawData !== "string") return; - let request: WsRequestEnvelope; - try { - request = JSON.parse(rawData) as WsRequestEnvelope; - } catch { - return; - } - const method = request.body?._tag; - if (typeof method !== "string") return; - wsRequests.push(request.body); - client.send( - JSON.stringify({ - id: request.id, - result: resolveWsRpc(request.body), - }), - ); - }); - }), - http.get("*/attachments/:attachmentId", () => - HttpResponse.text(ATTACHMENT_SVG, { - headers: { - "Content-Type": "image/svg+xml", - }, - }), - ), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), -); - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: ViewportSpec): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { - timeout: 4_000, - interval: 16, - }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function waitForURL( - router: ReturnType, - predicate: (pathname: string) => boolean, - errorMessage: string, -): Promise { - let pathname = ""; - await vi.waitFor( - () => { - pathname = router.state.location.pathname; - expect(predicate(pathname), errorMessage).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - return pathname; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[contenteditable="true"]'), - "Unable to find composer editor.", - ); -} - -async function waitForSendButton(): Promise { - return waitForElement( - () => document.querySelector('button[aria-label="Send message"]'), - "Unable to find send button.", - ); -} - -async function waitForInteractionModeButton( - expectedLabel: "Chat" | "Plan", -): Promise { - return waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === expectedLabel, - ) as HTMLButtonElement | null, - `Unable to find ${expectedLabel} interaction mode button.`, - ); -} - -async function waitForImagesToLoad(scope: ParentNode): Promise { - const images = Array.from(scope.querySelectorAll("img")); - if (images.length === 0) { - return; - } - await Promise.all( - images.map( - (image) => - new Promise((resolve) => { - if (image.complete) { - resolve(); - return; - } - image.addEventListener("load", () => resolve(), { once: true }); - image.addEventListener("error", () => resolve(), { once: true }); - }), - ), - ); - await waitForLayout(); -} - -async function measureUserRow(options: { - host: HTMLElement; - targetMessageId: MessageId; -}): Promise { - const { host, targetMessageId } = options; - const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; - - const scrollContainer = await waitForElement( - () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), - "Unable to find ChatView message scroll container.", - ); - - let row: HTMLElement | null = null; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - row = host.querySelector(rowSelector); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - - await waitForImagesToLoad(row!); - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - - const timelineRoot = - row!.closest('[data-timeline-root="true"]') ?? - host.querySelector('[data-timeline-root="true"]'); - if (!(timelineRoot instanceof HTMLElement)) { - throw new Error("Unable to locate timeline root container."); - } - - let timelineWidthMeasuredPx = 0; - let measuredRowHeightPx = 0; - let renderedInVirtualizedRegion = false; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); - timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; - renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; - expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); - }, - { - timeout: 4_000, - interval: 16, - }, - ); - - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; -} - -async function mountChatView(options: { - viewport: ViewportSpec; - snapshot: OrchestrationReadModel; - configureFixture?: (fixture: TestFixture) => void; -}): Promise { - fixture = buildFixture(options.snapshot); - options.configureFixture?.(fixture); - await setViewport(options.viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.inset = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], - }), - ); - - const screen = await render(, { - container: host, - }); - - await waitForLayout(); - - return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), - setViewport: async (viewport: ViewportSpec) => { - await setViewport(viewport); - await waitForProductionStyles(); - }, - router, - }; -} - -async function measureUserRowAtViewport(options: { - snapshot: OrchestrationReadModel; - targetMessageId: MessageId; - viewport: ViewportSpec; -}): Promise { - const mounted = await mountChatView({ - viewport: options.viewport, - snapshot: options.snapshot, - }); - - try { - return await mounted.measureUserRow(options.targetMessageId); - } finally { - await mounted.cleanup(); - } -} - -describe("ChatView timeline estimator parity (full app)", () => { - beforeAll(async () => { - fixture = buildFixture( - createSnapshotForTargetUser({ - targetMessageId: "msg-user-bootstrap" as MessageId, - targetText: "bootstrap", - }), - ); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { - url: "/mockServiceWorker.js", - }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - - beforeEach(async () => { - await setViewport(DEFAULT_VIEWPORT); - localStorage.clear(); - document.body.innerHTML = ""; - wsRequests.length = 0; - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: {}, - }); - useStore.setState({ - projects: [], - threads: [], - threadsHydrated: false, - }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it.each(TEXT_VIEWPORT_MATRIX)( - "keeps long user message estimate close at the $name viewport", - async (viewport) => { - const userText = "x".repeat(3_200); - const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); - - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); - - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); - - it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { - const userText = "x".repeat(3_200); - const targetMessageId = "msg-user-target-resize" as MessageId; - const mounted = await mountChatView({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } - > = []; - - for (const viewport of TEXT_VIEWPORT_MATRIX) { - await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: measurement.timelineWidthMeasuredPx }, - ); - - expect(measurement.renderedInVirtualizedRegion).toBe(true); - expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - measurements.push({ ...measurement, viewport, estimatedHeightPx }); - } - - expect( - new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))) - .size, - ).toBeGreaterThanOrEqual(3); - - const byMeasuredWidth = measurements.toSorted( - (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, - ); - const narrowest = byMeasuredWidth[0]!; - const widest = byMeasuredWidth.at(-1)!; - expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); - expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); - expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); - } finally { - await mounted.cleanup(); - } - }); - - it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { - const userText = "x".repeat(2_400); - const targetMessageId = "msg-user-target-wrap" as MessageId; - const snapshot = createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }); - const desktopMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot, - targetMessageId, - }); - const mobileMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[2], - snapshot, - targetMessageId, - }); - - const estimatedDesktopPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, - ); - const estimatedMobilePx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, - ); - - const measuredDeltaPx = - mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; - const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; - expect(measuredDeltaPx).toBeGreaterThan(0); - expect(estimatedDeltaPx).toBeGreaterThan(0); - const ratio = estimatedDeltaPx / measuredDeltaPx; - expect(ratio).toBeGreaterThan(0.65); - expect(ratio).toBeLessThan(1.35); - }); - - it.each(ATTACHMENT_VIEWPORT_MATRIX)( - "keeps user attachment estimate close at the $name viewport", - async (viewport) => { - const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; - const userText = "message with image attachments"; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - targetAttachmentCount: 3, - }), - }); - - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); - - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { - role: "user", - text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], - }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); - - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.attachmentTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); - - it("opens the project cwd for draft threads without a worktree path", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode"], - }; - }, - }); - - try { - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-hotkey" as MessageId, - targetText: "hotkey target", - }), - }); - - try { - const initialModeButton = await waitForInteractionModeButton("Chat"); - expect(initialModeButton.title).toContain("enter plan mode"); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); - - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor( - async () => { - expect((await waitForInteractionModeButton("Plan")).title).toContain( - "return to normal chat mode", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor( - async () => { - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps backspaced terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Backspace", - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), - ); - - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); - } finally { - await mounted.cleanup(); - } - }); - - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a pointer cursor for the running stop button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-stop-button-cursor" as MessageId, - targetText: "stop button cursor target", - sessionStatus: "running", - }), - }); - - try { - const stopButton = await waitForElement( - () => document.querySelector('button[aria-label="Stop generation"]'), - "Unable to find stop generation button.", - ); - - expect(getComputedStyle(stopButton).cursor).toBe("pointer"); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the new thread selected after clicking the new-thread button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-test" as MessageId, - targetText: "new thread selection test", - }), - }); - - try { - // Wait for the sidebar to render with the project. - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - // The route should change to a new draft thread ID. - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - // The composer editor should be present for the new draft thread. - await waitForComposerEditor(); - - // Simulate the snapshot sync arriving from the server after the draft - // thread has been promoted to a server thread (thread.create + turn.start - // succeeded). The snapshot now includes the new thread, and the sync - // should clear the draft without disrupting the route. - const { syncServerReadModel } = useStore.getState(); - syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); - - // Clear the draft now that the server thread exists (mirrors EventRouter behavior). - useComposerDraftStore.getState().clearDraftThread(newThreadId); - - // The route should still be on the new thread — not redirected away. - await waitForURL( - mounted.router, - (path) => path === newThreadPath, - "New thread should remain selected after snapshot sync clears the draft.", - ); - - // The empty thread view and composer should still be visible. - await expect - .element(page.getByText("Send a message to start the conversation.")) - .toBeInTheDocument(); - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("snapshots sticky codex settings into a new draft thread", async () => { - useComposerDraftStore.setState({ - stickyModel: "gpt-5.3-codex", - stickyModelOptions: { - codex: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, - targetText: "sticky codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - model: "gpt-5.3-codex", - provider: "codex", - modelOptions: { - codex: { - fastMode: true, - }, - }, - }); - } finally { - await mounted.cleanup(); - } - }); - - it("hydrates the provider alongside a sticky claude model", async () => { - useComposerDraftStore.setState({ - stickyModel: "claude-opus-4-6", - stickyModelOptions: { - claudeAgent: { - effort: "max", - fastMode: true, - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, - targetText: "sticky claude model test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new sticky claude draft thread UUID.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - provider: "claudeAgent", - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { - effort: "max", - fastMode: true, - }, - }, - }); - await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to defaults when no sticky composer settings exist", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-default-codex-traits-test" as MessageId, - targetText: "default codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); - } finally { - await mounted.cleanup(); - } - }); - - it("prefers draft state over sticky composer settings and defaults", async () => { - useComposerDraftStore.setState({ - stickyModel: "gpt-5.3-codex", - stickyModelOptions: { - codex: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, - targetText: "draft codex traits precedence test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const threadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a sticky draft thread UUID.", - ); - const threadId = threadPath.slice(1) as ThreadId; - - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.3-codex", - modelOptions: { - codex: { - fastMode: true, - }, - }, - }); - - useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); - useComposerDraftStore.getState().setModelOptions(threadId, { - codex: { - reasoningEffort: "low", - fastMode: true, - }, - }); - - await newThreadButton.click(); - - await waitForURL( - mounted.router, - (path) => path === threadPath, - "New-thread should reuse the existing project draft thread.", - ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.4", - modelOptions: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, - }, - }); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new thread from the global chat.new shortcut", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the shortcut.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a fresh draft after the previous draft thread is promoted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, - targetText: "promoted draft shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await newThreadButton.click(); - - const promotedThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a promoted draft thread UUID.", - ); - const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; - - const { syncServerReadModel } = useStore.getState(); - syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); - useComposerDraftStore.getState().clearDraftThread(promotedThreadId); - - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - - const freshThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, - "Shortcut should create a fresh draft instead of reusing the promoted thread.", - ); - expect(freshThreadPath).not.toBe(promotedThreadPath); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps long proposed plans lightweight until the user expands them", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithLongProposedPlan(), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - - expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); - - const expandButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - expandButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("deep hidden detail only after expand"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/src/components/ChatView.logic.test.ts b/apps/web/src/src/components/ChatView.logic.test.ts deleted file mode 100644 index bf72ec0..0000000 --- a/apps/web/src/src/components/ChatView.logic.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; - -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; - -describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ - prompt: "\uFFFC", - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.makeUnsafe("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(false); - }); - - it("keeps text sendable while excluding expired terminal pills", () => { - const state = deriveComposerSendState({ - prompt: `yoo \uFFFC waddup`, - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.makeUnsafe("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], - }); - - expect(state.trimmedPrompt).toBe("yoo waddup"); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(true); - }); -}); - -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats clear empty-state guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - }); - - it("formats omission guidance for sent messages", () => { - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); diff --git a/apps/web/src/src/components/ChatView.logic.ts b/apps/web/src/src/components/ChatView.logic.ts deleted file mode 100644 index ddc8471..0000000 --- a/apps/web/src/src/components/ChatView.logic.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ProjectId, type ThreadId } from "@t3tools/contracts"; -import { type ChatMessage, type Thread } from "../types"; -import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; -import { Schema } from "effect"; -import { - filterTerminalContextsWithText, - stripInlineTerminalContextPlaceholders, - type TerminalContextDraft, -} from "../lib/terminalContext"; - -export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -const WORKTREE_BRANCH_PREFIX = "t3code"; - -export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); - -export function buildLocalDraftThread( - threadId: ThreadId, - draftThread: DraftThreadState, - fallbackModel: string, - error: string | null, -): Thread { - return { - id: threadId, - codexThreadId: null, - projectId: draftThread.projectId, - title: "New thread", - model: fallbackModel, - runtimeMode: draftThread.runtimeMode, - interactionMode: draftThread.interactionMode, - session: null, - messages: [], - error, - createdAt: draftThread.createdAt, - latestTurn: null, - lastVisitedAt: draftThread.createdAt, - branch: draftThread.branch, - worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - }; -} - -export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { - return; - } - URL.revokeObjectURL(previewUrl); -} - -export function revokeUserMessagePreviewUrls(message: ChatMessage): void { - if (message.role !== "user" || !message.attachments) { - return; - } - for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; - } - revokeBlobPreviewUrl(attachment.previewUrl); - } -} - -export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { - if (message.role !== "user" || !message.attachments) { - return []; - } - const previewUrls: string[] = []; - for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; - previewUrls.push(attachment.previewUrl); - } - return previewUrls; -} - -export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; - -export interface PullRequestDialogState { - initialReference: string | null; - key: number; -} - -export function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("Could not read image data.")); - }); - reader.addEventListener("error", () => { - reject(reader.error ?? new Error("Failed to read image.")); - }); - reader.readAsDataURL(file); - }); -} - -export function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; -} - -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { - if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { - return image; - } - try { - return { - ...image, - previewUrl: URL.createObjectURL(image.file), - }; - } catch { - return image; - } -} - -export function deriveComposerSendState(options: { - prompt: string; - imageCount: number; - terminalContexts: ReadonlyArray; -}): { - trimmedPrompt: string; - sendableTerminalContexts: TerminalContextDraft[]; - expiredTerminalContextCount: number; - hasSendableContent: boolean; -} { - const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); - const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); - const expiredTerminalContextCount = - options.terminalContexts.length - sendableTerminalContexts.length; - return { - trimmedPrompt, - sendableTerminalContexts, - expiredTerminalContextCount, - hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, - }; -} - -export function buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount: number, - variant: "omitted" | "empty", -): { title: string; description: string } { - const count = Math.max(1, Math.floor(expiredTerminalContextCount)); - const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; - if (variant === "empty") { - return { - title: `${noun} won't be sent`, - description: "Remove it or re-add it to include terminal output.", - }; - } - return { - title: `${noun} omitted from message`, - description: "Re-add it if you want that terminal output included.", - }; -} diff --git a/apps/web/src/src/components/ChatView.tsx b/apps/web/src/src/components/ChatView.tsx deleted file mode 100644 index 6faf17f..0000000 --- a/apps/web/src/src/components/ChatView.tsx +++ /dev/null @@ -1,4207 +0,0 @@ -import { - type ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, - type ClaudeCodeEffort, - type MessageId, - type ProjectScript, - type ModelSlug, - type ProviderKind, - type ProjectEntry, - type ProjectId, - type ProviderApprovalDecision, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ResolvedKeybindingsConfig, - type ServerProviderStatus, - type ThreadId, - type TurnId, - type EditorId, - type KeybindingCommand, - OrchestrationThreadActivity, - ProviderInteractionMode, - RuntimeMode, -} from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultModel, - normalizeModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useDebouncedValue } from "@tanstack/react-pacer"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; -import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; -import { isElectron } from "../env"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { - clampCollapsedComposerCursor, - type ComposerTrigger, - collapseExpandedComposerCursor, - detectComposerTrigger, - expandCollapsedComposerCursor, - parseStandaloneComposerSlashCommand, - replaceTextRange, -} from "../composer-logic"; -import { - derivePendingApprovals, - derivePendingUserInputs, - derivePhase, - deriveTimelineEntries, - deriveActiveWorkStartedAt, - deriveActivePlanState, - findSidebarProposedPlan, - findLatestProposedPlan, - deriveWorkLogEntries, - hasActionableProposedPlan, - hasToolActivityForTurn, - isLatestTurnSettled, - formatElapsed, -} from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; -import { - buildPendingUserInputAnswers, - derivePendingUserInputProgress, - setPendingUserInputCustomAnswer, - type PendingUserInputDraftAnswer, -} from "../pendingUserInput"; -import { useStore } from "../store"; -import { - buildPlanImplementationThreadTitle, - buildPlanImplementationPrompt, - proposedPlanTitle, - resolvePlanFollowUpSubmission, -} from "../proposedPlan"; -import { truncateTitle } from "../truncateTitle"; -import { - DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, - DEFAULT_THREAD_TERMINAL_ID, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type TurnDiffSummary, -} from "../types"; -import { basenameOfPath } from "../vscode-icons"; -import { useTheme } from "../hooks/useTheme"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import BranchToolbar from "./BranchToolbar"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { - BotIcon, - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, - CircleAlertIcon, - ListTodoIcon, - LockIcon, - LockOpenIcon, - XIcon, -} from "lucide-react"; -import { Button } from "./ui/button"; -import { Separator } from "./ui/separator"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { cn, randomUUID } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; -import { type NewProjectScriptInput } from "./ProjectScriptsControl"; -import { - commandForProjectScript, - nextProjectScriptId, - projectScriptRuntimeEnv, - projectScriptIdFromCommand, - setupProjectScript, -} from "~/projectScripts"; -import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; -import { - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - resolveAppModelSelection, - useAppSettings, -} from "../appSettings"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { - type ComposerImageAttachment, - type DraftThreadEnvMode, - type PersistedComposerImageAttachment, - useComposerDraftStore, - useComposerThreadDraft, -} from "../composerDraftStore"; -import { - appendTerminalContextsToPrompt, - formatTerminalContextLabel, - insertInlineTerminalContextPlaceholder, - removeInlineTerminalContextPlaceholder, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; -import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; -import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./chat/MessagesTimeline"; -import { ChatHeader } from "./chat/ChatHeader"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; -import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; -import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; -import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; -import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; -import { - getComposerProviderState, - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; -import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; -import { - buildExpiredTerminalContextToastCopy, - buildLocalDraftThread, - buildTemporaryWorktreeBranchName, - cloneComposerImageForRetry, - collectUserMessageBlobPreviewUrls, - deriveComposerSendState, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, - PullRequestDialogState, - readFileAsDataUrl, - revokeBlobPreviewUrl, - revokeUserMessagePreviewUrls, - SendPhase, -} from "./ChatView.logic"; -import { useLocalStorage } from "~/hooks/useLocalStorage"; - -const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; -const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; -const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; -const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; -const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; -const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; - -function formatOutgoingPrompt(params: { - provider: ProviderKind; - effort: string | null; - text: string; -}): string { - if (params.provider === "claudeAgent" && params.effort === "ultrathink") { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); - } - return params.text; -} -const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; - -const extendReplacementRangeForTrailingSpace = ( - text: string, - rangeEnd: number, - replacement: string, -): number => { - if (!replacement.endsWith(" ")) { - return rangeEnd; - } - return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; -}; - -const syncTerminalContextsByIds = ( - contexts: ReadonlyArray, - ids: ReadonlyArray, -): TerminalContextDraft[] => { - const contextsById = new Map(contexts.map((context) => [context.id, context])); - return ids.flatMap((id) => { - const context = contextsById.get(id); - return context ? [context] : []; - }); -}; - -const terminalContextIdListsEqual = ( - contexts: ReadonlyArray, - ids: ReadonlyArray, -): boolean => - contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); - -interface ChatViewProps { - threadId: ThreadId; -} - -export default function ChatView({ threadId }: ChatViewProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); - const markThreadVisited = useStore((store) => store.markThreadVisited); - const syncServerReadModel = useStore((store) => store.syncServerReadModel); - const setStoreThreadError = useStore((store) => store.setError); - const setStoreThreadBranch = useStore((store) => store.setThreadBranch); - const { settings } = useAppSettings(); - const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); - const timestampFormat = settings.timestampFormat; - const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); - const { resolvedTheme } = useTheme(); - const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); - const composerDraft = useComposerThreadDraft(threadId); - const prompt = composerDraft.prompt; - const composerImages = composerDraft.images; - const composerTerminalContexts = composerDraft.terminalContexts; - const composerSendState = useMemo( - () => - deriveComposerSendState({ - prompt, - imageCount: composerImages.length, - terminalContexts: composerTerminalContexts, - }), - [composerImages.length, composerTerminalContexts, prompt], - ); - const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); - const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); - const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); - const setComposerDraftInteractionMode = useComposerDraftStore( - (store) => store.setInteractionMode, - ); - const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); - const insertComposerDraftTerminalContext = useComposerDraftStore( - (store) => store.insertTerminalContext, - ); - const addComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.addTerminalContexts, - ); - const removeComposerDraftTerminalContext = useComposerDraftStore( - (store) => store.removeTerminalContext, - ); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); - const clearComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.clearPersistedAttachments, - ); - const syncComposerDraftPersistedAttachments = useComposerDraftStore( - (store) => store.syncPersistedAttachments, - ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, - ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, - ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const promptRef = useRef(prompt); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [expandedImage, setExpandedImage] = useState(null); - const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - optimisticUserMessagesRef.current = optimisticUserMessages; - const composerTerminalContextsRef = useRef(composerTerminalContexts); - const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< - Record - >({}); - const [sendPhase, setSendPhase] = useState("idle"); - const [sendStartedAt, setSendStartedAt] = useState(null); - const [isConnecting, _setIsConnecting] = useState(false); - const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState([]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< - ApprovalRequestId[] - >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); - // Tracks whether the user explicitly dismissed the sidebar for the active turn. - const planSidebarDismissedForTurnRef = useRef(null); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); - const [nowTick, setNowTick] = useState(() => Date.now()); - const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); - const [pullRequestDialogState, setPullRequestDialogState] = - useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); - const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), - ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); - const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); - const shouldAutoScrollRef = useRef(true); - const lastKnownScrollTopRef = useRef(0); - const isPointerScrollActiveRef = useRef(false); - const lastTouchClientYRef = useRef(null); - const pendingUserScrollUpIntentRef = useRef(false); - const pendingAutoScrollFrameRef = useRef(null); - const pendingInteractionAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const pendingInteractionAnchorFrameRef = useRef(null); - const composerEditorRef = useRef(null); - const composerFormRef = useRef(null); - const composerFormHeightRef = useRef(0); - const composerImagesRef = useRef([]); - const composerSelectLockRef = useRef(false); - const composerMenuOpenRef = useRef(false); - const composerMenuItemsRef = useRef([]); - const activeComposerMenuItemRef = useRef(null); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); - const sendInFlightRef = useRef(false); - const dragDepthRef = useRef(0); - const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); - - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), - ); - const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); - const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); - const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - - const setPrompt = useCallback( - (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); - }, - [setComposerDraftPrompt, threadId], - ); - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); - }, - [addComposerDraftImage, threadId], - ); - const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); - }, - [addComposerDraftImages, threadId], - ); - const addComposerTerminalContextsToDraft = useCallback( - (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); - }, - [addComposerDraftTerminalContexts, threadId], - ); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - removeComposerDraftImage(threadId, imageId); - }, - [removeComposerDraftImage, threadId], - ); - const removeComposerTerminalContextFromDraft = useCallback( - (contextId: string) => { - const contextIndex = composerTerminalContexts.findIndex( - (context) => context.id === contextId, - ); - if (contextIndex < 0) { - return; - } - const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); - promptRef.current = nextPrompt.prompt; - setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); - setComposerCursor(nextPrompt.cursor); - setComposerTrigger( - detectComposerTrigger( - nextPrompt.prompt, - expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), - ), - ); - }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], - ); - - const serverThread = threads.find((t) => t.id === threadId); - const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); - const localDraftThread = useMemo( - () => - draftThread - ? buildLocalDraftThread( - threadId, - draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, - localDraftError, - ) - : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], - ); - const activeThread = serverThread ?? localDraftThread; - const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const isServerThread = serverThread !== undefined; - const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; - const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; - const activeThreadId = activeThread?.id ?? null; - const activeLatestTurn = activeThread?.latestTurn ?? null; - const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = projects.find((p) => p.id === activeThread?.projectId); - - - const openPullRequestDialog = useCallback( - (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - setPullRequestDialogState({ - initialReference: reference ?? null, - key: Date.now(), - }); - setComposerHighlightedItemId(null); - }, - [canCheckoutPullRequestIntoThread], - ); - - const closePullRequestDialog = useCallback(() => { - setPullRequestDialogState(null); - }, []); - - const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const storedDraftThread = getDraftThreadByProjectId(activeProject.id); - if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); - if (storedDraftThread.threadId !== threadId) { - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - } - return; - } - - const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); - return; - } - - clearProjectDraftThreadId(activeProject.id); - const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { - createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, - }); - }, - [ - activeProject, - clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectId, - isServerThread, - navigate, - setDraftThreadContext, - setProjectDraftThreadId, - threadId, - ], - ); - - const handlePreparedPullRequestThread = useCallback( - async (input: { branch: string; worktreePath: string | null }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", - }); - }, - [openOrReuseProjectDraftThread], - ); - - useEffect(() => { - if (!activeThread?.id) return; - if (!latestTurnSettled) return; - if (!activeLatestTurn?.completedAt) return; - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - - markThreadVisited(activeThread.id); - }, [ - activeThread?.id, - activeThread?.lastVisitedAt, - activeLatestTurn?.completedAt, - latestTurnSettled, - markThreadVisited, - ]); - - const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = composerDraft.provider; - const hasThreadStarted = Boolean( - activeThread && - (activeThread.latestTurn !== null || - activeThread.messages.length > 0 || - activeThread.session !== null), - ); - const lockedProvider: ProviderKind | null = hasThreadStarted - ? (sessionProvider ?? selectedProviderByThreadId ?? null) - : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; - const baseThreadModel = resolveModelSlugForProvider( - selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), - ); - const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); - const selectedModel = useMemo(() => { - const draftModel = composerDraft.model; - if (!draftModel) { - return baseThreadModel; - } - return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); - }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); - const draftModelOptions = composerDraft.modelOptions; - const composerProviderState = useMemo( - () => - getComposerProviderState({ - provider: selectedProvider, - model: selectedModel, - prompt, - modelOptions: draftModelOptions, - }), - [draftModelOptions, prompt, selectedModel, selectedProvider], - ); - const selectedPromptEffort = composerProviderState.promptEffort; - const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - const providerOptionsForDispatch = useMemo(() => { - if (!settings.codexBinaryPath && !settings.codexHomePath) { - return undefined; - } - return { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - }; - }, [settings.codexBinaryPath, settings.codexHomePath]); - const selectedModelForPicker = selectedModel; - const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings), - [settings], - ); - const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) - ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); - }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], - ); - const phase = derivePhase(activeThread?.session ?? null); - const isSendBusy = sendPhase !== "idle"; - const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestTurn, - activeThread?.session ?? null, - sendStartedAt, - ); - const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - const latestTurnHasToolActivity = useMemo( - () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), - [activeLatestTurn?.turnId, threadActivities], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(threadActivities), - [threadActivities], - ); - const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], - ); - const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingDraftAnswers = useMemo( - () => - activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) - : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], - ); - const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) - : 0; - const activePendingProgress = useMemo( - () => - activePendingUserInput - ? derivePendingUserInputProgress( - activePendingUserInput.questions, - activePendingDraftAnswers, - activePendingQuestionIndex, - ) - : null, - [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], - ); - const activePendingResolvedAnswers = useMemo( - () => - activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) - : null, - [activePendingDraftAnswers, activePendingUserInput], - ); - const activePendingIsResponding = activePendingUserInput - ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) - : false; - const activeProposedPlan = useMemo(() => { - if (!latestTurnSettled) { - return null; - } - return findLatestProposedPlan( - activeThread?.proposedPlans ?? [], - activeLatestTurn?.turnId ?? null, - ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], - ); - const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - const showPlanFollowUpPrompt = - pendingUserInputs.length === 0 && - interactionMode === "plan" && - latestTurnSettled && - hasActionableProposedPlan(activeProposedPlan); - const activePendingApproval = pendingApprovals[0] ?? null; - const isComposerApprovalState = activePendingApproval !== null; - const hasComposerHeader = - isComposerApprovalState || - pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const lastSyncedPendingInputRef = useRef<{ - requestId: string | null; - questionId: string | null; - } | null>(null); - useEffect(() => { - const nextCustomAnswer = activePendingProgress?.customAnswer; - if (typeof nextCustomAnswer !== "string") { - lastSyncedPendingInputRef.current = null; - return; - } - const nextRequestId = activePendingUserInput?.requestId ?? null; - const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; - const questionChanged = - lastSyncedPendingInputRef.current?.requestId !== nextRequestId || - lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; - const textChangedExternally = promptRef.current !== nextCustomAnswer; - - lastSyncedPendingInputRef.current = { - requestId: nextRequestId, - questionId: nextQuestionId, - }; - - if (!questionChanged && !textChangedExternally) { - return; - } - - promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); - setComposerHighlightedItemId(null); - }, [ - activePendingProgress?.customAnswer, - activePendingUserInput?.requestId, - activePendingProgress?.activeQuestion?.id, - ]); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { - window.clearTimeout(timeoutId); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }, []); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffs(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - for (const previewUrl of previousPreviewUrls) { - if (!previewUrls.includes(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - - const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { - const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, []); - const serverMessages = activeThread?.messages; - const timelineMessages = useMemo(() => { - const messages = serverMessages ?? []; - const serverMessagesWithPreviewHandoff = - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? messages - : // Spread only fires for the few messages that actually changed; - // unchanged ones early-return their original reference. - // In-place mutation would break React's immutable state contract. - // oxlint-disable-next-line no-map-spread - messages.map((message) => { - if ( - message.role !== "user" || - !message.attachments || - message.attachments.length === 0 - ) { - return message; - } - const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; - if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { - return message; - } - - let changed = false; - let imageIndex = 0; - const attachments = message.attachments.map((attachment) => { - if (attachment.type !== "image") { - return attachment; - } - const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; - imageIndex += 1; - if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { - return attachment; - } - changed = true; - return { - ...attachment, - previewUrl: handoffPreviewUrl, - }; - }); - - return changed ? { ...message, attachments } : message; - }); - - if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); - if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); - const timelineEntries = useMemo( - () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], - ); - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const turnDiffSummaryByAssistantMessageId = useMemo(() => { - const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - byMessageId.set(summary.assistantMessageId, summary); - } - return byMessageId; - }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo(() => { - const byUserMessageId = new Map(); - for (let index = 0; index < timelineEntries.length; index += 1) { - const entry = timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { - const nextEntry = timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; - } - } - - return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - - const completionSummary = useMemo(() => { - if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; - if (!latestTurnHasToolActivity) return null; - - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); - return elapsed ? `Worked for ${elapsed}` : null; - }, [ - activeLatestTurn?.completedAt, - activeLatestTurn?.startedAt, - latestTurnHasToolActivity, - latestTurnSettled, - ]); - const completionDividerBeforeEntryId = useMemo(() => { - if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; - if (!completionSummary) return null; - - const turnStartedAt = Date.parse(activeLatestTurn.startedAt); - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnStartedAt)) return null; - if (Number.isNaN(turnCompletedAt)) return null; - - let inRangeMatch: string | null = null; - let fallbackMatch: string | null = null; - for (const timelineEntry of timelineEntries) { - if (timelineEntry.kind !== "message") continue; - if (timelineEntry.message.role !== "assistant") continue; - const messageAt = Date.parse(timelineEntry.message.createdAt); - if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; - fallbackMatch = timelineEntry.id; - if (messageAt <= turnCompletedAt) { - inRangeMatch = timelineEntry.id; - } - } - return inRangeMatch ?? fallbackMatch; - }, [ - activeLatestTurn?.completedAt, - activeLatestTurn?.startedAt, - completionSummary, - latestTurnSettled, - timelineEntries, - ]); - const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; - const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; - const isPathTrigger = composerTriggerKind === "path"; - const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( - pathTriggerQuery, - { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, - (debouncerState) => ({ isPending: debouncerState.isPending }), - ); - const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const workspaceEntriesQuery = useQuery( - projectSearchEntriesQueryOptions({ - cwd: gitCwd, - query: effectivePathQuery, - enabled: isPathTrigger, - limit: 80, - }), - ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; - const composerMenuItems = useMemo(() => { - if (!composerTrigger) return []; - if (composerTrigger.kind === "path") { - return workspaceEntries.map((entry) => ({ - id: `path:${entry.kind}:${entry.path}`, - type: "path", - path: entry.path, - pathKind: entry.kind, - label: basenameOfPath(entry.path), - description: entry.parentPath ?? "", - })); - } - - if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal chat mode", - }, - ] satisfies ReadonlyArray>; - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); - } - - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); - const activeComposerMenuItem = useMemo( - () => - composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? - composerMenuItems[0] ?? - null, - [composerHighlightedItemId, composerMenuItems], - ); - composerMenuOpenRef.current = composerMenuOpen; - composerMenuItemsRef.current = composerMenuItems; - activeComposerMenuItemRef.current = activeComposerMenuItem; - const nonPersistedComposerImageIdSet = useMemo( - () => new Set(nonPersistedComposerImageIds), - [nonPersistedComposerImageIds], - ); - const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, - [selectedProvider, providerStatuses], - ); - const activeProjectCwd = activeProject?.cwd ?? null; - const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const threadTerminalRuntimeEnv = useMemo(() => { - if (!activeProjectCwd) return {}; - return projectScriptRuntimeEnv({ - project: { - cwd: activeProjectCwd, - }, - worktreePath: activeThreadWorktreePath, - }); - }, [activeProjectCwd, activeThreadWorktreePath]); - // Default true while loading to avoid toolbar flicker. - const isGitRepo = branchesQuery.data?.isRepo ?? true; - const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split"), - [keybindings], - ); - const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new"), - [keybindings], - ); - const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close"), - [keybindings], - ); - const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle"), - [keybindings], - ); - const onToggleDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, navigate, threadId]); - const envLocked = Boolean( - activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "closed")), - ); - const activeTerminalGroup = - terminalState.terminalGroups.find( - (group) => group.id === terminalState.activeTerminalGroupId, - ) ?? - terminalState.terminalGroups.find((group) => - group.terminalIds.includes(terminalState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; - const setThreadError = useCallback( - (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - if (threads.some((thread) => thread.id === targetThreadId)) { - setStoreThreadError(targetThreadId, error); - return; - } - setLocalDraftErrorsByThreadId((existing) => { - if ((existing[targetThreadId] ?? null) === error) { - return existing; - } - return { - ...existing, - [targetThreadId]: error, - }; - }); - }, - [setStoreThreadError, threads], - ); - - const focusComposer = useCallback(() => { - composerEditorRef.current?.focusAtEnd(); - }, []); - const scheduleComposerFocus = useCallback(() => { - window.requestAnimationFrame(() => { - focusComposer(); - }); - }, [focusComposer]); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { - if (!activeThread) { - return; - } - const snapshot = composerEditorRef.current?.readSnapshot() ?? { - value: promptRef.current, - cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - terminalContextIds: composerTerminalContexts.map((context) => context.id), - }; - const insertion = insertInlineTerminalContextPlaceholder( - snapshot.value, - snapshot.expandedCursor, - ); - const nextCollapsedCursor = collapseExpandedComposerCursor( - insertion.prompt, - insertion.cursor, - ); - const inserted = insertComposerDraftTerminalContext( - activeThread.id, - insertion.prompt, - { - id: randomUUID(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - ...selection, - }, - insertion.contextIndex, - ); - if (!inserted) { - return; - } - promptRef.current = insertion.prompt; - setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCollapsedCursor); - }); - }, - [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], - ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); - }, - [activeThreadId, storeSetTerminalOpen], - ); - const setTerminalHeight = useCallback( - (height: number) => { - if (!activeThreadId) return; - storeSetTerminalHeight(activeThreadId, height); - }, - [activeThreadId, storeSetTerminalHeight], - ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); - const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; - const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); - const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; - const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); - const activateTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadId) return; - storeSetActiveTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeSetActiveTerminal], - ); - const closeTerminal = useCallback( - (terminalId: string) => { - const api = readNativeApi(); - if (!activeThreadId || !api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const fallbackExitWrite = () => - api.terminal - .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) - .catch(() => undefined); - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal - .clear({ threadId: activeThreadId, terminalId }) - .catch(() => undefined); - } - await api.terminal.close({ - threadId: activeThreadId, - terminalId, - deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } - storeCloseTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], - ); - const runProjectScript = useCallback( - async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - allowLocalDraftThread?: boolean; - }, - ) => { - const api = readNativeApi(); - if (!api || !activeThreadId || !activeProject || !activeThread) return; - if (!isServerThread && !options?.allowLocalDraftThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; - const baseTerminalId = - terminalState.activeTerminalId || - terminalState.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; - const targetTerminalId = shouldCreateNewTerminal - ? `terminal-${randomUUID()}` - : baseTerminalId; - - setTerminalOpen(true); - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); - } - setTerminalFocusRequestId((value) => value + 1); - - const runtimeEnv = projectScriptRuntimeEnv({ - project: { - cwd: activeProject.cwd, - }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - const openTerminalInput: Parameters[0] = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - }; - - try { - await api.terminal.open(openTerminalInput); - await api.terminal.write({ - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }); - } catch (error) { - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - }, - [ - activeProject, - activeThread, - activeThreadId, - gitCwd, - isServerThread, - setTerminalOpen, - setThreadError, - storeNewTerminal, - storeSetActiveTerminal, - setLastInvokedScriptByProjectId, - terminalState.activeTerminalId, - terminalState.runningTerminalIds, - terminalState.terminalIds, - ], - ); - const persistProjectScripts = useCallback( - async (input: { - projectId: ProjectId; - projectCwd: string; - previousScripts: ProjectScript[]; - nextScripts: ProjectScript[]; - keybinding?: string | null; - keybindingCommand: KeybindingCommand; - }) => { - const api = readNativeApi(); - if (!api) return; - - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: input.projectId, - scripts: input.nextScripts, - }); - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); - - if (isElectron && keybindingRule) { - await api.server.upsertKeybinding(keybindingRule); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - } - }, - [queryClient], - ); - const saveProjectScript = useCallback( - async (input: NewProjectScriptInput) => { - if (!activeProject) return; - const nextId = nextProjectScriptId( - input.name, - activeProject.scripts.map((script) => script.id), - ); - const nextScript: ProjectScript = { - id: nextId, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; - const nextScripts = input.runOnWorktreeCreate - ? [ - ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...activeProject.scripts, nextScript]; - - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(nextId), - }); - }, - [activeProject, persistProjectScripts], - ); - const updateProjectScript = useCallback( - async (scriptId: string, input: NewProjectScriptInput) => { - if (!activeProject) return; - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); - if (!existingScript) { - throw new Error("Script not found."); - } - - const updatedScript: ProjectScript = { - ...existingScript, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; - const nextScripts = activeProject.scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(scriptId), - }); - }, - [activeProject, persistProjectScripts], - ); - const deleteProjectScript = useCallback( - async (scriptId: string) => { - if (!activeProject) return; - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); - - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; - - try { - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: null, - keybindingCommand: commandForProjectScript(scriptId), - }); - toastManager.add({ - type: "success", - title: `Deleted action "${deletedName ?? "Unknown"}"`, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - } - }, - [activeProject, persistProjectScripts], - ); - - const handleRuntimeModeChange = useCallback( - (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); - if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }, - [ - isLocalDraftThread, - runtimeMode, - scheduleComposerFocus, - setComposerDraftRuntimeMode, - setDraftThreadContext, - threadId, - ], - ); - - const handleInteractionModeChange = useCallback( - (mode: ProviderInteractionMode) => { - if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); - if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); - } - scheduleComposerFocus(); - }, - [ - interactionMode, - isLocalDraftThread, - scheduleComposerFocus, - setComposerDraftInteractionMode, - setDraftThreadContext, - threadId, - ], - ); - const toggleInteractionMode = useCallback(() => { - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }, [handleInteractionModeChange, interactionMode]); - const toggleRuntimeMode = useCallback(() => { - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ); - }, [handleRuntimeModeChange, runtimeMode]); - const togglePlanSidebar = useCallback(() => { - setPlanSidebarOpen((open) => { - if (open) { - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } - } else { - planSidebarDismissedForTurnRef.current = null; - } - return !open; - }); - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); - - const persistThreadSettingsForNextTurn = useCallback( - async (input: { - threadId: ThreadId; - createdAt: string; - model?: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - }) => { - if (!serverThread) { - return; - } - const api = readNativeApi(); - if (!api) { - return; - } - - if (input.model !== undefined && input.model !== serverThread.model) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: input.threadId, - model: input.model, - }); - } - - if (input.runtimeMode !== serverThread.runtimeMode) { - await api.orchestration.dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: newCommandId(), - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, - }); - } - - if (input.interactionMode !== serverThread.interactionMode) { - await api.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: newCommandId(), - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, - }); - } - }, - [serverThread], - ); - - // Auto-scroll on new messages - const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); - const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current; - if (pendingFrame === null) return; - pendingAutoScrollFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const cancelPendingInteractionAnchorAdjustment = useCallback(() => { - const pendingFrame = pendingInteractionAnchorFrameRef.current; - if (pendingFrame === null) return; - pendingInteractionAnchorFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const scheduleStickToBottom = useCallback(() => { - if (pendingAutoScrollFrameRef.current !== null) return; - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - scrollMessagesToBottom(); - }); - }, [scrollMessagesToBottom]); - const onMessagesClickCapture = useCallback( - (event: React.MouseEvent) => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer || !(event.target instanceof Element)) return; - - const trigger = event.target.closest( - "button, summary, [role='button'], [data-scroll-anchor-target]", - ); - if (!trigger || !scrollContainer.contains(trigger)) return; - if (trigger.closest("[data-scroll-anchor-ignore]")) return; - - pendingInteractionAnchorRef.current = { - element: trigger, - top: trigger.getBoundingClientRect().top, - }; - - cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); - }, - [cancelPendingInteractionAnchorAdjustment], - ); - const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom(); - scrollMessagesToBottom(); - scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); - const onMessagesScroll = useCallback(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - const currentScrollTop = scrollContainer.scrollTop; - const isNearBottom = isScrollContainerNearBottom(scrollContainer); - - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } - - setShowScrollToBottom(!shouldAutoScrollRef.current); - lastKnownScrollTopRef.current = currentScrollTop; - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); - useEffect(() => { - return () => { - cancelPendingStickToBottom(); - cancelPendingInteractionAnchorAdjustment(); - }; - }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); - useLayoutEffect(() => { - if (!activeThread?.id) return; - shouldAutoScrollRef.current = true; - scheduleStickToBottom(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); - return () => { - window.clearTimeout(timeout); - }; - }, [activeThread?.id, scheduleStickToBottom]); - useLayoutEffect(() => { - const composerForm = composerFormRef.current; - if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - - composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - setIsComposerFooterCompact( - shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }), - ); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry) return; - - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); - setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); - - const nextHeight = entry.contentRect.height; - const previousHeight = composerFormHeightRef.current; - composerFormHeightRef.current = nextHeight; - - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }); - - observer.observe(composerForm); - return () => { - observer.disconnect(); - }; - }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); - useEffect(() => { - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [messageCount, scheduleStickToBottom]); - useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); - - useEffect(() => { - setExpandedWorkGroups({}); - setPullRequestDialogState(null); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(true); - } else { - setPlanSidebarOpen(false); - } - planSidebarDismissedForTurnRef.current = null; - }, [activeThread?.id]); - - useEffect(() => { - if (!composerMenuOpen) { - setComposerHighlightedItemId(null); - return; - } - setComposerHighlightedItemId((existing) => - existing && composerMenuItems.some((item) => item.id === existing) - ? existing - : (composerMenuItems[0]?.id ?? null), - ); - }, [composerMenuItems, composerMenuOpen]); - - useEffect(() => { - setIsRevertingCheckpoint(false); - }, [activeThread?.id]); - - useEffect(() => { - if (!activeThread?.id || terminalState.terminalOpen) return; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); - - useEffect(() => { - composerImagesRef.current = composerImages; - }, [composerImages]); - - useEffect(() => { - composerTerminalContextsRef.current = composerTerminalContexts; - }, [composerTerminalContexts]); - - useEffect(() => { - if (!activeThread?.id) return; - if (activeThread.messages.length === 0) { - return; - } - const serverIds = new Set(activeThread.messages.map((message) => message.id)); - const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); - if (removedMessages.length === 0) { - return; - } - const timer = window.setTimeout(() => { - setOptimisticUserMessages((existing) => - existing.filter((message) => !serverIds.has(message.id)), - ); - }, 0); - for (const removedMessage of removedMessages) { - const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); - if (previewUrls.length > 0) { - handoffAttachmentPreviews(removedMessage.id, previewUrls); - continue; - } - revokeUserMessagePreviewUrls(removedMessage); - } - return () => { - window.clearTimeout(timer); - }; - }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); - - useEffect(() => { - promptRef.current = prompt; - setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt]); - - useEffect(() => { - setOptimisticUserMessages((existing) => { - for (const message of existing) { - revokeUserMessagePreviewUrls(message); - } - return []; - }); - setSendPhase("idle"); - setSendStartedAt(null); - setComposerHighlightedItemId(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - setExpandedImage(null); - }, [threadId]); - - useEffect(() => { - let cancelled = false; - void (async () => { - if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); - return; - } - const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; - try { - const currentPersistedAttachments = getPersistedAttachmentsForThread(); - const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), - ); - const stagedAttachmentById = new Map(); - await Promise.all( - composerImages.map(async (image) => { - try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl, - }); - } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); - } - } - }), - ); - const serialized = Array.from(stagedAttachmentById.values()); - if (cancelled) { - return; - } - // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(threadId, serialized); - } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); - const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); - const fallbackPersistedIds = fallbackPersistedAttachments - .map((attachment) => attachment.id) - .filter((id) => currentImageIds.has(id)); - const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => - fallbackPersistedIdSet.has(attachment.id), - ); - if (cancelled) { - return; - } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); - } - })(); - return () => { - cancelled = true; - }; - }, [ - clearComposerDraftPersistedAttachments, - composerImages, - syncComposerDraftPersistedAttachments, - threadId, - ]); - - const closeExpandedImage = useCallback(() => { - setExpandedImage(null); - }, []); - const navigateExpandedImage = useCallback((direction: -1 | 1) => { - setExpandedImage((existing) => { - if (!existing || existing.images.length <= 1) { - return existing; - } - const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; - if (nextIndex === existing.index) { - return existing; - } - return { ...existing, index: nextIndex }; - }); - }, []); - - useEffect(() => { - if (!expandedImage) { - return; - } - - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - closeExpandedImage(); - return; - } - if (expandedImage.images.length <= 1) { - return; - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - event.stopPropagation(); - navigateExpandedImage(-1); - return; - } - if (event.key !== "ArrowRight") return; - event.preventDefault(); - event.stopPropagation(); - navigateExpandedImage(1); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [closeExpandedImage, expandedImage, navigateExpandedImage]); - - const activeWorktreePath = activeThread?.worktreePath; - const envMode: DraftThreadEnvMode = activeWorktreePath - ? "worktree" - : isLocalDraftThread - ? (draftThread?.envMode ?? "local") - : "local"; - - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - - const beginSendPhase = useCallback((nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, []); - - const resetSendPhase = useCallback(() => { - setSendPhase("idle"); - setSendStartedAt(null); - }, []); - - useEffect(() => { - if (sendPhase === "idle") { - return; - } - if ( - phase === "running" || - activePendingApproval !== null || - activePendingUserInput !== null || - activeThread?.error - ) { - resetSendPhase(); - } - }, [ - activePendingApproval, - activePendingUserInput, - activeThread?.error, - phase, - resetSendPhase, - sendPhase, - ]); - - useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; - const current = Boolean(terminalState.terminalOpen); - - if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; - setTerminalFocusRequestId((value) => value + 1); - return; - } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - } - - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); - - useEffect(() => { - const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; - const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(terminalState.terminalOpen), - }; - - const command = resolveShortcutCommand(event, keybindings, { - context: shortcutContext, - }); - if (!command) return; - - if (command === "terminal.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleTerminalVisibility(); - return; - } - - if (command === "terminal.split") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminal(); - return; - } - - if (command === "terminal.close") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) return; - closeTerminal(terminalState.activeTerminalId); - return; - } - - if (command === "terminal.new") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - createNewTerminal(); - return; - } - - if (command === "diff.toggle") { - event.preventDefault(); - event.stopPropagation(); - onToggleDiff(); - return; - } - - const scriptId = projectScriptIdFromCommand(command); - if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); - if (!script) return; - event.preventDefault(); - event.stopPropagation(); - void runProjectScript(script); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [ - activeProject, - terminalState.terminalOpen, - terminalState.activeTerminalId, - activeThreadId, - closeTerminal, - createNewTerminal, - setTerminalOpen, - runProjectScript, - splitTerminal, - keybindings, - onToggleDiff, - toggleTerminalVisibility, - ]); - - const addComposerImages = (files: File[]) => { - if (!activeThreadId || files.length === 0) return; - - if (pendingUserInputs.length > 0) { - toastManager.add({ - type: "error", - title: "Attach images after answering plan questions.", - }); - return; - } - - const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; - let error: string | null = null; - for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; - continue; - } - if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; - } - - const previewUrl = URL.createObjectURL(file); - nextImages.push({ - type: "image", - id: randomUUID(), - name: file.name || "image", - mimeType: file.type, - sizeBytes: file.size, - previewUrl, - file, - }); - nextImageCount += 1; - } - - if (nextImages.length === 1 && nextImages[0]) { - addComposerImage(nextImages[0]); - } else if (nextImages.length > 1) { - addComposerImagesToDraft(nextImages); - } - setThreadError(activeThreadId, error); - }; - - const removeComposerImage = (imageId: string) => { - removeComposerImageFromDraft(imageId); - }; - - const onComposerPaste = (event: React.ClipboardEvent) => { - const files = Array.from(event.clipboardData.files); - if (files.length === 0) { - return; - } - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) { - return; - } - event.preventDefault(); - addComposerImages(imageFiles); - }; - - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); - }; - - const onRevertToTurnCount = useCallback( - async (turnCount: number) => { - const api = readNativeApi(); - if (!api || !activeThread || isRevertingCheckpoint) return; - - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - const confirmed = await api.dialogs.confirm( - [ - `Revert this thread to checkpoint ${turnCount}?`, - "This will discard newer messages and turn diffs in this thread.", - "This action cannot be undone.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - - setIsRevertingCheckpoint(true); - setThreadError(activeThread.id, null); - try { - await api.orchestration.dispatchCommand({ - type: "thread.checkpoint.revert", - commandId: newCommandId(), - threadId: activeThread.id, - turnCount, - createdAt: new Date().toISOString(), - }); - } catch (err) { - setThreadError( - activeThread.id, - err instanceof Error ? err.message : "Failed to revert thread state.", - ); - } - setIsRevertingCheckpoint(false); - }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], - ); - - const onSend = async (e?: { preventDefault: () => void }) => { - e?.preventDefault(); - const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; - if (activePendingProgress) { - onAdvanceActivePendingUserInput(); - return; - } - const promptForSend = promptRef.current; - const { - trimmedPrompt: trimmed, - sendableTerminalContexts: sendableComposerTerminalContexts, - expiredTerminalContextCount, - hasSendableContent, - } = deriveComposerSendState({ - prompt: promptForSend, - imageCount: composerImages.length, - terminalContexts: composerTerminalContexts, - }); - if (showPlanFollowUpPrompt && activeProposedPlan) { - const followUp = resolvePlanFollowUpSubmission({ - draftText: trimmed, - planMarkdown: activeProposedPlan.planMarkdown, - }); - promptRef.current = ""; - clearComposerDraftContent(activeThread.id); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); - await onSubmitPlanFollowUp({ - text: followUp.text, - interactionMode: followUp.interactionMode, - }); - return; - } - const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) - : null; - if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); - promptRef.current = ""; - clearComposerDraftContent(activeThread.id); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); - return; - } - if (!hasSendableContent) { - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "empty", - ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); - } - return; - } - if (!activeProject) return; - const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeThread.messages.length === 0; - const baseBranchForWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath - ? activeThread.branch - : null; - - // In worktree mode, require an explicit base branch so we don't silently - // fall back to local execution when branch selection is missing. - const shouldCreateWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThread.branch) { - setStoreThreadError( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); - return; - } - - sendInFlightRef.current = true; - beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); - - const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( - promptForSend, - composerTerminalContextsSnapshot, - ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - effort: selectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, - }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), - ); - const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - previewUrl: image.previewUrl, - })); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), - createdAt: messageCreatedAt, - streaming: false, - }, - ]); - // Sending a message should always bring the latest user turn into view. - shouldAutoScrollRef.current = true; - forceStickToBottom(); - - setThreadError(threadIdForSend, null); - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "omitted", - ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); - } - promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); - - let createdServerThreadForLocalDraft = false; - let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; - await (async () => { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); - } - } - - let firstComposerImageName: string | null = null; - if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; - } - } - let titleSeed = trimmed; - if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); - } else { - titleSeed = "New thread"; - } - } - const title = truncateTitle(titleSeed); - let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; - - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - model: threadCreateModel, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } - - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); - } - if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - allowLocalDraftThread: createdServerThreadForLocalDraft, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); - } - } - - // Auto-title from first message - if (isFirstMessage && isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - title, - }); - } - - if (isServerThread) { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), - runtimeMode, - interactionMode, - }); - } - - beginSendPhase("sending-turn"); - const turnAttachments = await turnAttachmentsPromise; - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachments, - }, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", - runtimeMode, - interactionMode, - createdAt: messageCreatedAt, - }); - turnStartSucceeded = true; - })().catch(async (err: unknown) => { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } - if ( - !turnStartSucceeded && - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - promptRef.current = promptForSend; - setPrompt(promptForSend); - setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); - addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); - setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); - } - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send message.", - ); - }); - sendInFlightRef.current = false; - if (!turnStartSucceeded) { - resetSendPhase(); - } - }; - - const onInterrupt = async () => { - const api = readNativeApi(); - if (!api || !activeThread) return; - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - }); - }; - - const onRespondToApproval = useCallback( - async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const api = readNativeApi(); - if (!api || !activeThreadId) return; - - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - await api.orchestration - .dispatchCommand({ - type: "thread.approval.respond", - commandId: newCommandId(), - threadId: activeThreadId, - requestId, - decision, - createdAt: new Date().toISOString(), - }) - .catch((err: unknown) => { - setStoreThreadError( - activeThreadId, - err instanceof Error ? err.message : "Failed to submit approval decision.", - ); - }); - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - }, - [activeThreadId, setStoreThreadError], - ); - - const onRespondToUserInput = useCallback( - async (requestId: ApprovalRequestId, answers: Record) => { - const api = readNativeApi(); - if (!api || !activeThreadId) return; - - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - await api.orchestration - .dispatchCommand({ - type: "thread.user-input.respond", - commandId: newCommandId(), - threadId: activeThreadId, - requestId, - answers, - createdAt: new Date().toISOString(), - }) - .catch((err: unknown) => { - setStoreThreadError( - activeThreadId, - err instanceof Error ? err.message : "Failed to submit user input.", - ); - }); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - }, - [activeThreadId, setStoreThreadError], - ); - - const setActivePendingUserInputQuestionIndex = useCallback( - (nextQuestionIndex: number) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); - }, - [activePendingUserInput], - ); - - const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionLabel: string) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: { - selectedOptionLabel: optionLabel, - customAnswer: "", - }, - }, - })); - promptRef.current = ""; - setComposerCursor(0); - setComposerTrigger(null); - }, - [activePendingUserInput], - ); - - const onChangeActivePendingUserInputCustomAnswer = useCallback( - ( - questionId: string, - value: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - ) => { - if (!activePendingUserInput) { - return; - } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), - ); - }, - [activePendingUserInput], - ); - - const onAdvanceActivePendingUserInput = useCallback(() => { - if (!activePendingUserInput || !activePendingProgress) { - return; - } - if (activePendingProgress.isLastQuestion) { - if (activePendingResolvedAnswers) { - void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); - } - return; - } - setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); - }, [ - activePendingProgress, - activePendingResolvedAnswers, - activePendingUserInput, - onRespondToUserInput, - setActivePendingUserInputQuestionIndex, - ]); - - const onPreviousActivePendingUserInputQuestion = useCallback(() => { - if (!activePendingProgress) { - return; - } - setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); - }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); - - const onSubmitPlanFollowUp = useCallback( - async ({ - text, - interactionMode: nextInteractionMode, - }: { - text: string; - interactionMode: "default" | "plan"; - }) => { - const api = readNativeApi(); - if ( - !api || - !activeThread || - !isServerThread || - isSendBusy || - isConnecting || - sendInFlightRef.current - ) { - return; - } - - const trimmed = text.trim(); - if (!trimmed) { - return; - } - - const threadIdForSend = activeThread.id; - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - effort: selectedPromptEffort, - text: trimmed, - }); - - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - setThreadError(threadIdForSend, null); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - createdAt: messageCreatedAt, - streaming: false, - }, - ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); - - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), - runtimeMode, - interactionMode: nextInteractionMode, - }); - - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); - - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - provider: selectedProvider, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); - } - sendInFlightRef.current = false; - } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", - ); - sendInFlightRef.current = false; - resetSendPhase(); - } - }, - [ - activeThread, - activeProposedPlan, - beginSendPhase, - forceStickToBottom, - isConnecting, - isSendBusy, - isServerThread, - persistThreadSettingsForNextTurn, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, - providerOptionsForDispatch, - selectedProvider, - setComposerDraftInteractionMode, - setThreadError, - settings.enableAssistantStreaming, - ], - ); - - const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); - if ( - !api || - !activeThread || - !activeProject || - !activeProposedPlan || - !isServerThread || - isSendBusy || - isConnecting || - sendInFlightRef.current - ) { - return; - } - - const createdAt = new Date().toISOString(); - const nextThreadId = newThreadId(); - const planMarkdown = activeProposedPlan.planMarkdown; - const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const outgoingImplementationPrompt = formatOutgoingPrompt({ - provider: selectedProvider, - effort: selectedPromptEffort, - text: implementationPrompt, - }); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModel: ModelSlug = - selectedModel || - (activeThread.model as ModelSlug) || - (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; - - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - const finish = () => { - sendInFlightRef.current = false; - resetSendPhase(); - }; - - await api.orchestration - .dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - model: nextThreadModel, - runtimeMode, - interactionMode: "default", - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - createdAt, - }) - .then(() => { - return api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - provider: selectedProvider, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", - runtimeMode, - interactionMode: "default", - createdAt, - }); - }) - .then(() => api.orchestration.getSnapshot()) - .then((snapshot) => { - syncServerReadModel(snapshot); - // Signal that the plan sidebar should open on the new thread. - planSidebarOpenOnNextThreadRef.current = true; - return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, - }); - }) - .catch(async (err) => { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, - }) - .catch(() => undefined); - await api.orchestration - .getSnapshot() - .then((snapshot) => { - syncServerReadModel(snapshot); - }) - .catch(() => undefined); - toastManager.add({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", - }); - }) - .then(finish, finish); - }, [ - activeProject, - activeProposedPlan, - activeThread, - beginSendPhase, - isConnecting, - isSendBusy, - isServerThread, - navigate, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, - providerOptionsForDispatch, - selectedProvider, - settings.enableAssistantStreaming, - syncServerReadModel, - ]); - - const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: ModelSlug) => { - if (!activeThread) return; - if (lockedProvider !== null && provider !== lockedProvider) { - scheduleComposerFocus(); - return; - } - const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel(activeThread.id, resolvedModel); - setStickyComposerModel(resolvedModel); - scheduleComposerFocus(); - }, - [ - activeThread, - lockedProvider, - scheduleComposerFocus, - setComposerDraftModel, - setComposerDraftProvider, - setStickyComposerModel, - customModelsByProvider, - ], - ); - const setPromptFromTraits = useCallback( - (nextPrompt: string) => { - const currentPrompt = promptRef.current; - if (nextPrompt === currentPrompt) { - scheduleComposerFocus(); - return; - } - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); - setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setPrompt], - ); - const providerTraitsMenuContent = renderProviderTraitsMenuContent({ - provider: selectedProvider, - threadId, - model: selectedModel, - onPromptChange: setPromptFromTraits, - }); - const providerTraitsPicker = renderProviderTraitsPicker({ - provider: selectedProvider, - threadId, - model: selectedModel, - onPromptChange: setPromptFromTraits, - }); - const onEnvModeChange = useCallback( - (mode: DraftThreadEnvMode) => { - if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); - } - scheduleComposerFocus(); - }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], - ); - - const applyPromptReplacement = useCallback( - ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { expectedText?: string }, - ): boolean => { - const currentText = promptRef.current; - const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); - if ( - options?.expectedText !== undefined && - currentText.slice(safeStart, safeEnd) !== options.expectedText - ) { - return false; - } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); - promptRef.current = next.text; - const activePendingQuestion = activePendingProgress?.activeQuestion; - if (activePendingQuestion && activePendingUserInput) { - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], - next.text, - ), - }, - })); - } else { - setPrompt(next.text); - } - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), - ); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); - return true; - }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], - ); - - const readComposerSnapshot = useCallback((): { - value: string; - cursor: number; - expandedCursor: number; - terminalContextIds: string[]; - } => { - const editorSnapshot = composerEditorRef.current?.readSnapshot(); - if (editorSnapshot) { - return editorSnapshot; - } - return { - value: promptRef.current, - cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - terminalContextIds: composerTerminalContexts.map((context) => context.id), - }; - }, [composerCursor, composerTerminalContexts]); - - const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number; expandedCursor: number }; - trigger: ComposerTrigger | null; - } => { - const snapshot = readComposerSnapshot(); - return { - snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), - }; - }, [readComposerSnapshot]); - - const onSelectComposerItem = useCallback( - (item: ComposerCommandItem) => { - if (composerSelectLockRef.current) return; - composerSelectLockRef.current = true; - window.requestAnimationFrame(() => { - composerSelectLockRef.current = false; - }); - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - if (item.type === "path") { - const replacement = `@${item.path} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - if (item.type === "slash-command") { - if (item.command === "model") { - const replacement = "/model "; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } - return; - } - onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } - }, - [ - applyPromptReplacement, - handleInteractionModeChange, - onProviderModelSelect, - resolveActiveComposerTrigger, - ], - ); - const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { - setComposerHighlightedItemId(itemId); - }, []); - const nudgeComposerMenuHighlight = useCallback( - (key: "ArrowDown" | "ArrowUp") => { - if (composerMenuItems.length === 0) { - return; - } - const highlightedIndex = composerMenuItems.findIndex( - (item) => item.id === composerHighlightedItemId, - ); - const normalizedIndex = - highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; - const offset = key === "ArrowDown" ? 1 : -1; - const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; - const nextItem = composerMenuItems[nextIndex]; - setComposerHighlightedItemId(nextItem?.id ?? null); - }, - [composerHighlightedItemId, composerMenuItems], - ); - const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); - - const onPromptChange = useCallback( - ( - nextPrompt: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - terminalContextIds: string[], - ) => { - if (activePendingProgress?.activeQuestion && activePendingUserInput) { - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; - } - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { - setComposerDraftTerminalContexts( - threadId, - syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), - ); - } - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - composerTerminalContexts, - onChangeActivePendingUserInputCustomAnswer, - setPrompt, - setComposerDraftTerminalContexts, - threadId, - ], - ); - - const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => { - if (key === "Tab" && event.shiftKey) { - toggleInteractionMode(); - return true; - } - - const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = composerMenuOpenRef.current || trigger !== null; - - if (menuIsActive) { - const currentItems = composerMenuItemsRef.current; - if (key === "ArrowDown" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowDown"); - return true; - } - if (key === "ArrowUp" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowUp"); - return true; - } - if (key === "Tab" || key === "Enter") { - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; - if (selectedItem) { - onSelectComposerItem(selectedItem); - return true; - } - } - } - - if (key === "Enter" && !event.shiftKey) { - void onSend(); - return true; - } - return false; - }; - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); - const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { - setExpandedImage(preview); - }, []); - const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; - const onOpenTurnDiff = useCallback( - (turnId: TurnId, filePath?: string) => { - void navigate({ - to: "/$threadId", - params: { threadId }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }, - [navigate, threadId], - ); - const onRevertUserMessage = (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }; - - // Empty state: no active thread - if (!activeThread) { - return ( -
- {!isElectron && ( -
-
- - Threads -
-
- )} - {isElectron && ( -
- No active thread -
- )} -
-
-

Select a thread or create a new one to get started.

-
-
-
- ); - } - - return ( -
- {/* Top bar */} -
- { - void runProjectScript(script); - }} - onAddProjectScript={saveProjectScript} - onUpdateProjectScript={updateProjectScript} - onDeleteProjectScript={deleteProjectScript} - onToggleDiff={onToggleDiff} - /> -
- - {/* Error banner */} - - setThreadError(activeThread.id, null)} - /> - {/* Main content area with optional plan sidebar */} -
- {/* Chat column */} -
- {/* Messages Wrapper */} -
- {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeProject?.cwd ?? undefined} - /> -
- - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- -
- )} -
- - {/* Input bar */} -
-
-
-
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} -
- {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - - - - - - - - {activePlan || sidebarProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
- - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
- - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in a new thread - - - -
- ) - ) : ( - - ) - ) : null} -
-
- )} -
-
-
-
- - {isGitRepo && ( - - )} - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} -
- {/* end chat column */} - - {/* Plan sidebar */} - {planSidebarOpen ? ( - { - setPlanSidebarOpen(false); - // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } - }} - /> - ) : null} -
- {/* end horizontal flex container */} - - {(() => { - if (!terminalState.terminalOpen || !activeProject) { - return null; - } - return ( - - ); - })()} - - {expandedImage && expandedImageItem && ( -
- - )} -
- - {expandedImageItem.name} -

- {expandedImageItem.name} - {expandedImage.images.length > 1 - ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` - : ""} -

-
- {expandedImage.images.length > 1 && ( - - )} -
- )} -
- ); -} diff --git a/apps/web/src/src/components/ComposerPromptEditor.tsx b/apps/web/src/src/components/ComposerPromptEditor.tsx deleted file mode 100644 index 338d9f7..0000000 --- a/apps/web/src/src/components/ComposerPromptEditor.tsx +++ /dev/null @@ -1,1177 +0,0 @@ -import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { ContentEditable } from "@lexical/react/LexicalContentEditable"; -import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; -import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; -import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; -import { - $applyNodeReplacement, - $createRangeSelection, - $getSelection, - $setSelection, - $isElementNode, - $isLineBreakNode, - $isRangeSelection, - $isTextNode, - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_LEFT_COMMAND, - KEY_ARROW_RIGHT_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_ENTER_COMMAND, - KEY_TAB_COMMAND, - COMMAND_PRIORITY_HIGH, - KEY_BACKSPACE_COMMAND, - $getRoot, - DecoratorNode, - type ElementNode, - type LexicalNode, - type SerializedLexicalNode, - TextNode, - type EditorConfig, - type EditorState, - type NodeKey, - type SerializedTextNode, - type Spread, -} from "lexical"; -import { - createContext, - forwardRef, - useCallback, - useContext, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - type ClipboardEventHandler, - type ReactElement, - type Ref, -} from "react"; - -import { - clampCollapsedComposerCursor, - collapseExpandedComposerCursor, - expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, -} from "~/composer-logic"; -import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, -} from "~/lib/terminalContext"; -import { cn } from "~/lib/utils"; -import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; -import { - COMPOSER_INLINE_CHIP_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, -} from "./composerInlineChip"; -import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; - -const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; - -type SerializedComposerMentionNode = Spread< - { - path: string; - type: "composer-mention"; - version: 1; - }, - SerializedTextNode ->; - -type SerializedComposerTerminalContextNode = Spread< - { - context: TerminalContextDraft; - type: "composer-terminal-context"; - version: 1; - }, - SerializedLexicalNode ->; - -const ComposerTerminalContextActionsContext = createContext<{ - onRemoveTerminalContext: (contextId: string) => void; -}>({ - onRemoveTerminalContext: () => {}, -}); - -class ComposerMentionNode extends TextNode { - __path: string; - - static override getType(): string { - return "composer-mention"; - } - - static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); - } - - constructor(path: string, key?: NodeKey) { - const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); - this.__path = normalizedPath; - } - - override exportJSON(): SerializedComposerMentionNode { - return { - ...super.exportJSON(), - path: this.__path, - type: "composer-mention", - version: 1, - }; - } - - override createDOM(_config: EditorConfig): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); - return dom; - } - - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } - return false; - } - - override canInsertTextBefore(): false { - return false; - } - - override canInsertTextAfter(): false { - return false; - } - - override isTextEntity(): true { - return true; - } - - override isToken(): true { - return true; - } -} - -function $createComposerMentionNode(path: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path)); -} - -function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { - return ; -} - -class ComposerTerminalContextNode extends DecoratorNode { - __context: TerminalContextDraft; - - static override getType(): string { - return "composer-terminal-context"; - } - - static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { - return new ComposerTerminalContextNode(node.__context, node.__key); - } - - static override importJSON( - serializedNode: SerializedComposerTerminalContextNode, - ): ComposerTerminalContextNode { - return $createComposerTerminalContextNode(serializedNode.context); - } - - constructor(context: TerminalContextDraft, key?: NodeKey) { - super(key); - this.__context = context; - } - - override exportJSON(): SerializedComposerTerminalContextNode { - return { - ...super.exportJSON(), - context: this.__context, - type: "composer-terminal-context", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; - } - - override isInline(): true { - return true; - } - - override decorate(): ReactElement { - return ; - } -} - -function $createComposerTerminalContextNode( - context: TerminalContextDraft, -): ComposerTerminalContextNode { - return $applyNodeReplacement(new ComposerTerminalContextNode(context)); -} - -type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; - -function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return ( - candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode - ); -} - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); -} - -function terminalContextSignature(contexts: ReadonlyArray): string { - return contexts - .map((context) => - [ - context.id, - context.threadId, - context.terminalId, - context.terminalLabel, - context.lineStart, - context.lineEnd, - context.createdAt, - context.text, - ].join("\u001f"), - ) - .join("\u001e"); -} - -function clampExpandedCursor(value: string, cursor: number): number { - if (!Number.isFinite(cursor)) return value.length; - return Math.max(0, Math.min(value.length, Math.floor(cursor))); -} - -function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { - return 1; -} - -function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { - return node.getTextContentSize(); -} - -function getAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); -} - -function getExpandedAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); -} - -function findSelectionPointForInlineToken( - node: ComposerInlineTokenNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "element" } | null { - const parent = node.getParent(); - if (!parent || !$isElementNode(parent)) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === getComposerInlineTokenTextLength(node)) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= getComposerInlineTokenTextLength(node); - return null; -} - -function getComposerNodeTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); - } - return 0; -} - -function getComposerNodeExpandedTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenExpandedTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node - .getChildren() - .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); - } - return 0; -} - -function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (node instanceof ComposerTerminalContextNode) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeTextLength(child); - } - return offset; - } - - return offset; -} - -function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeExpandedTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (node instanceof ComposerTerminalContextNode) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeExpandedTextLength(child); - } - return offset; - } - - return offset; -} - -function findSelectionPointAtOffset( - node: LexicalNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - if (node instanceof ComposerTerminalContextNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - - if ($isTextNode(node)) { - const size = node.getTextContentSize(); - if (remainingRef.value <= size) { - return { - key: node.getKey(), - offset: remainingRef.value, - type: "text", - }; - } - remainingRef.value -= size; - return null; - } - - if ($isLineBreakNode(node)) { - const parent = node.getParent(); - if (!parent) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === 1) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= 1; - return null; - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - for (const child of children) { - const point = findSelectionPointAtOffset(child, remainingRef); - if (point) { - return point; - } - } - if (remainingRef.value === 0) { - return { - key: node.getKey(), - offset: children.length, - type: "element", - }; - } - } - - return null; -} - -function $getComposerRootLength(): number { - const root = $getRoot(); - const children = root.getChildren(); - return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); -} - -function $setSelectionAtComposerOffset(nextOffset: number): void { - const root = $getRoot(); - const composerLength = $getComposerRootLength(); - const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); - const remainingRef = { value: boundedOffset }; - const point = findSelectionPointAtOffset(root, remainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const selection = $createRangeSelection(); - selection.anchor.set(point.key, point.offset, point.type); - selection.focus.set(point.key, point.offset, point.type); - $setSelection(selection); -} - -function $readSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const composerLength = $getComposerRootLength(); - return Math.max(0, Math.min(offset, composerLength)); -} - -function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const expandedLength = $getRoot().getTextContent().length; - return Math.max(0, Math.min(offset, expandedLength)); -} - -function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { - const lines = text.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (line.length > 0) { - parent.append($createTextNode(line)); - } - if (index < lines.length - 1) { - parent.append($createLineBreakNode()); - } - } -} - -function $setComposerEditorPrompt( - prompt: string, - terminalContexts: ReadonlyArray, -): void { - const root = $getRoot(); - root.clear(); - const paragraph = $createParagraphNode(); - root.append(paragraph); - - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); - for (const segment of segments) { - if (segment.type === "mention") { - paragraph.append($createComposerMentionNode(segment.path)); - continue; - } - if (segment.type === "terminal-context") { - if (segment.context) { - paragraph.append($createComposerTerminalContextNode(segment.context)); - } - continue; - } - $appendTextWithLineBreaks(paragraph, segment.text); - } -} - -function collectTerminalContextIds(node: LexicalNode): string[] { - if (node instanceof ComposerTerminalContextNode) { - return [node.__context.id]; - } - if ($isElementNode(node)) { - return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); - } - return []; -} - -export interface ComposerPromptEditorHandle { - focus: () => void; - focusAt: (cursor: number) => void; - focusAtEnd: () => void; - readSnapshot: () => { - value: string; - cursor: number; - expandedCursor: number; - terminalContextIds: string[]; - }; -} - -interface ComposerPromptEditorProps { - value: string; - cursor: number; - terminalContexts: ReadonlyArray; - disabled: boolean; - placeholder: string; - className?: string; - onRemoveTerminalContext: (contextId: string) => void; - onChange: ( - nextValue: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - terminalContextIds: string[], - ) => void; - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; - onPaste: ClipboardEventHandler; -} - -interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps { - editorRef: Ref; -} - -function ComposerCommandKeyPlugin(props: { - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; -}) { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent | null, - ): boolean => { - if (!props.onCommandKeyDown || !event) { - return false; - } - const handled = props.onCommandKeyDown(key, event); - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - return handled; - }; - - const unregisterArrowDown = editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => handleCommand("ArrowDown", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterArrowUp = editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => handleCommand("ArrowUp", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterEnter = editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => handleCommand("Enter", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterTab = editor.registerCommand( - KEY_TAB_COMMAND, - (event) => handleCommand("Tab", event), - COMMAND_PRIORITY_HIGH, - ); - - return () => { - unregisterArrowDown(); - unregisterArrowUp(); - unregisterEnter(); - unregisterTab(); - }; - }, [editor, props]); - - return null; -} - -function ComposerInlineTokenArrowPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const unregisterLeft = editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - if (currentOffset <= 0) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { - return; - } - nextOffset = currentOffset - 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - const unregisterRight = editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - const composerLength = $getComposerRootLength(); - if (currentOffset >= composerLength) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { - return; - } - nextOffset = currentOffset + 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - return () => { - unregisterLeft(); - unregisterRight(); - }; - }, [editor]); - - return null; -} - -function ComposerInlineTokenSelectionNormalizePlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerUpdateListener(({ editorState }) => { - let afterOffset: number | null = null; - editorState.read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const anchorNode = selection.anchor.getNode(); - if (!isComposerInlineTokenNode(anchorNode)) return; - if (selection.anchor.offset === 0) return; - const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); - afterOffset = beforeOffset + 1; - }); - if (afterOffset !== null) { - queueMicrotask(() => { - editor.update(() => { - $setSelectionAtComposerOffset(afterOffset!); - }); - }); - } - }); - }, [editor]); - - return null; -} - -function ComposerInlineTokenBackspacePlugin() { - const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); - - useEffect(() => { - return editor.registerCommand( - KEY_BACKSPACE_COMMAND, - (event) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return false; - } - - const anchorNode = selection.anchor.getNode(); - const selectionOffset = $readSelectionOffsetFromEditorState(0); - const removeInlineTokenNode = (candidate: unknown): boolean => { - if (!isComposerInlineTokenNode(candidate)) { - return false; - } - const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); - candidate.remove(); - if (candidate instanceof ComposerTerminalContextNode) { - onRemoveTerminalContext(candidate.__context.id); - $setSelectionAtComposerOffset(selectionOffset); - } else { - $setSelectionAtComposerOffset(tokenStart); - } - event?.preventDefault(); - return true; - }; - if (removeInlineTokenNode(anchorNode)) { - return true; - } - - if ($isTextNode(anchorNode)) { - if (selection.anchor.offset > 0) { - return false; - } - if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { - return true; - } - const parent = anchorNode.getParent(); - if ($isElementNode(parent)) { - const index = anchorNode.getIndexWithinParent(); - if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { - return true; - } - } - return false; - } - - if ($isElementNode(anchorNode)) { - const childIndex = selection.anchor.offset - 1; - if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { - return true; - } - } - - return false; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor, onRemoveTerminalContext]); - - return null; -} - -function ComposerPromptEditorInner({ - value, - cursor, - terminalContexts, - disabled, - placeholder, - className, - onRemoveTerminalContext, - onChange, - onCommandKeyDown, - onPaste, - editorRef, -}: ComposerPromptEditorInnerProps) { - const [editor] = useLexicalComposerContext(); - const onChangeRef = useRef(onChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); - const terminalContextsSignature = terminalContextSignature(terminalContexts); - const terminalContextsSignatureRef = useRef(terminalContextsSignature); - const snapshotRef = useRef({ - value, - cursor: initialCursor, - expandedCursor: expandCollapsedComposerCursor(value, initialCursor), - terminalContextIds: terminalContexts.map((context) => context.id), - }); - const isApplyingControlledUpdateRef = useRef(false); - const terminalContextActions = useMemo( - () => ({ onRemoveTerminalContext }), - [onRemoveTerminalContext], - ); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - editor.setEditable(!disabled); - }, [disabled, editor]); - - useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); - const previousSnapshot = snapshotRef.current; - const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; - if ( - previousSnapshot.value === value && - previousSnapshot.cursor === normalizedCursor && - !contextsChanged - ) { - return; - } - - snapshotRef.current = { - value, - cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), - terminalContextIds: terminalContexts.map((context) => context.id), - }; - terminalContextsSignatureRef.current = terminalContextsSignature; - - const rootElement = editor.getRootElement(); - const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !isFocused) { - return; - } - - isApplyingControlledUpdateRef.current = true; - editor.update(() => { - const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; - if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts); - } - if (shouldRewriteEditorState || isFocused) { - $setSelectionAtComposerOffset(normalizedCursor); - } - }); - queueMicrotask(() => { - isApplyingControlledUpdateRef.current = false; - }); - }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); - - const focusAt = useCallback( - (nextCursor: number) => { - const rootElement = editor.getRootElement(); - if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); - rootElement.focus(); - editor.update(() => { - $setSelectionAtComposerOffset(boundedCursor); - }); - snapshotRef.current = { - value: snapshotRef.current.value, - cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - terminalContextIds: snapshotRef.current.terminalContextIds, - }; - onChangeRef.current( - snapshotRef.current.value, - boundedCursor, - snapshotRef.current.expandedCursor, - false, - snapshotRef.current.terminalContextIds, - ); - }, - [editor], - ); - - const readSnapshot = useCallback((): { - value: string; - cursor: number; - expandedCursor: number; - terminalContextIds: string[]; - } => { - let snapshot = snapshotRef.current; - editor.getEditorState().read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const terminalContextIds = collectTerminalContextIds($getRoot()); - snapshot = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - terminalContextIds, - }; - }); - snapshotRef.current = snapshot; - return snapshot; - }, [editor]); - - useImperativeHandle( - editorRef, - () => ({ - focus: () => { - focusAt(snapshotRef.current.cursor); - }, - focusAt, - focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); - }, - readSnapshot, - }), - [focusAt, readSnapshot], - ); - - const handleEditorChange = useCallback((editorState: EditorState) => { - editorState.read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const terminalContextIds = collectTerminalContextIds($getRoot()); - const previousSnapshot = snapshotRef.current; - if ( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) - ) { - return; - } - if (isApplyingControlledUpdateRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - terminalContextIds, - }; - const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - terminalContextIds, - ); - }); - }, []); - - return ( - -
- } - onPaste={onPaste} - /> - } - placeholder={ - terminalContexts.length > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - -
-
- ); -} - -export const ComposerPromptEditor = forwardRef< - ComposerPromptEditorHandle, - ComposerPromptEditorProps ->(function ComposerPromptEditor( - { - value, - cursor, - terminalContexts, - disabled, - placeholder, - className, - onRemoveTerminalContext, - onChange, - onCommandKeyDown, - onPaste, - }, - ref, -) { - const initialValueRef = useRef(value); - const initialTerminalContextsRef = useRef(terminalContexts); - const initialConfig = useMemo( - () => ({ - namespace: "t3tools-composer-editor", - editable: true, - nodes: [ComposerMentionNode, ComposerTerminalContextNode], - editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); - }, - onError: (error) => { - throw error; - }, - }), - [], - ); - - return ( - - - - ); -}); diff --git a/apps/web/src/src/components/DiffPanel.tsx b/apps/web/src/src/components/DiffPanel.tsx deleted file mode 100644 index 34ad788..0000000 --- a/apps/web/src/src/components/DiffPanel.tsx +++ /dev/null @@ -1,609 +0,0 @@ -import { parsePatchFiles } from "@pierre/diffs"; -import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; -import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { ThreadId, type TurnId } from "@t3tools/contracts"; -import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; -import { - type WheelEvent as ReactWheelEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { openInPreferredEditor } from "../editorPreferences"; -import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; -import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; -import { cn } from "~/lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { resolvePathLinkTarget } from "../terminal-links"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { useTheme } from "../hooks/useTheme"; -import { buildPatchCacheKey } from "../lib/diffRendering"; -import { resolveDiffThemeName } from "../lib/diffRendering"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useStore } from "../store"; -import { useAppSettings } from "../appSettings"; -import { formatShortTimestamp } from "../timestampFormat"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; - -type DiffRenderMode = "stacked" | "split"; -type DiffThemeType = "light" | "dark"; - -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - -[data-file-info] { - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-block-color: var(--border) !important; - color: var(--foreground) !important; -} - -[data-diffs-header] { - position: sticky !important; - top: 0; - z-index: 4; - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-bottom: 1px solid var(--border) !important; -} - -[data-title] { - cursor: pointer; - transition: - color 120ms ease, - text-decoration-color 120ms ease; - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 2px; -} - -[data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; - text-decoration-color: currentColor; -} -`; - -type RenderablePatch = - | { - kind: "files"; - files: FileDiffMetadata[]; - } - | { - kind: "raw"; - text: string; - reason: string; - }; - -function getRenderablePatch( - patch: string | undefined, - cacheScope = "diff-panel", -): RenderablePatch | null { - if (!patch) return null; - const normalizedPatch = patch.trim(); - if (normalizedPatch.length === 0) return null; - - try { - const parsedPatches = parsePatchFiles( - normalizedPatch, - buildPatchCacheKey(normalizedPatch, cacheScope), - ); - const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); - if (files.length > 0) { - return { kind: "files", files }; - } - - return { - kind: "raw", - text: normalizedPatch, - reason: "Unsupported diff format. Showing raw patch.", - }; - } catch { - return { - kind: "raw", - text: normalizedPatch, - reason: "Failed to parse patch. Showing raw patch.", - }; - } -} - -function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; -} - -function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { - return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; -} - -interface DiffPanelProps { - mode?: DiffPanelMode; -} - -export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; - -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); - const { settings } = useAppSettings(); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); - const routeThreadId = useParams({ - strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), - }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const activeThreadId = routeThreadId; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, - ); - const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, - ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); - const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const orderedTurnDiffSummaries = useMemo( - () => - [...turnDiffSummaries].toSorted((left, right) => { - const leftTurnCount = - left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; - const rightTurnCount = - right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; - if (leftTurnCount !== rightTurnCount) { - return rightTurnCount - leftTurnCount; - } - return right.completedAt.localeCompare(left.completedAt); - }), - [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], - ); - - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; - const selectedTurn = - selectedTurnId === null - ? undefined - : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? - orderedTurnDiffSummaries[0]); - const selectedCheckpointTurnCount = - selectedTurn && - (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const selectedCheckpointRange = useMemo( - () => - typeof selectedCheckpointTurnCount === "number" - ? { - fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), - toTurnCount: selectedCheckpointTurnCount, - } - : null, - [selectedCheckpointTurnCount], - ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts = orderedTurnDiffSummaries - .map( - (summary) => - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], - ) - .filter((value): value is number => typeof value === "number"); - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); - const activeCheckpointDiffQuery = useQuery( - checkpointDiffQueryOptions({ - threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, - }), - ); - const selectedTurnCheckpointDiff = selectedTurn - ? activeCheckpointDiffQuery.data?.diff - : undefined; - const conversationCheckpointDiff = selectedTurn - ? undefined - : activeCheckpointDiffQuery.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; - const checkpointDiffError = - activeCheckpointDiffQuery.error instanceof Error - ? activeCheckpointDiffQuery.error.message - : activeCheckpointDiffQuery.error - ? "Failed to load checkpoint diff." - : null; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; - const hasResolvedPatch = typeof selectedPatch === "string"; - const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; - const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], - ); - const renderableFiles = useMemo(() => { - if (!renderablePatch || renderablePatch.kind !== "files") { - return []; - } - return renderablePatch.files.toSorted((left, right) => - resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { - numeric: true, - sensitivity: "base", - }), - ); - }, [renderablePatch]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); - - const openDiffFileInEditor = useCallback( - (filePath: string) => { - const api = readNativeApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); - }); - }, - [activeCwd], - ); - - const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); - - const headerRow = ( - <> -
- {canScrollTurnStripLeft && ( -
- )} - {canScrollTurnStripRight && ( -
- )} - - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - ))} -
-
- { - const next = value[0]; - if (next === "stacked" || next === "split") { - setDiffRenderMode(next); - } - }} - > - - - - - - - - - ); - - return ( - - {!activeThread ? ( -
- Select a thread to inspect turn diffs. -
- ) : !isGitRepo ? ( -
- Turn diffs are unavailable because this project is not a git repository. -
- ) : orderedTurnDiffSummaries.length === 0 ? ( -
- No completed turns yet. -
- ) : ( - <> -
- {checkpointDiffError && !renderablePatch && ( -
-

{checkpointDiffError}

-
- )} - {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - - ) : ( -
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

-
- ) - ) : renderablePatch.kind === "files" ? ( - - {renderableFiles.map((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff); - const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${resolvedTheme}`; - return ( -
{ - const nativeEvent = event.nativeEvent as MouseEvent; - const composedPath = nativeEvent.composedPath?.() ?? []; - const clickedHeader = composedPath.some((node) => { - if (!(node instanceof Element)) return false; - return node.hasAttribute("data-title"); - }); - if (!clickedHeader) return; - openDiffFileInEditor(filePath); - }} - > - -
- ); - })} -
- ) : ( -
-
-

{renderablePatch.reason}

-
-                    {renderablePatch.text}
-                  
-
-
- )} -
- - )} -
- ); -} diff --git a/apps/web/src/src/components/DiffPanelShell.tsx b/apps/web/src/src/components/DiffPanelShell.tsx deleted file mode 100644 index c08c533..0000000 --- a/apps/web/src/src/components/DiffPanelShell.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { ReactNode } from "react"; - -import { isElectron } from "~/env"; -import { cn } from "~/lib/utils"; - -import { Skeleton } from "./ui/skeleton"; - -export type DiffPanelMode = "inline" | "sheet" | "sidebar"; - -function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; - return cn( - "flex items-center justify-between gap-2 px-4", - shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", - ); -} - -export function DiffPanelShell(props: { - mode: DiffPanelMode; - header: ReactNode; - children: ReactNode; -}) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; - - return ( -
- {shouldUseDragRegion ? ( -
{props.header}
- ) : ( -
-
{props.header}
-
- )} - {props.children} -
- ); -} - -export function DiffPanelHeaderSkeleton() { - return ( - <> -
- - -
- - - -
-
-
- - -
- - ); -} - -export function DiffPanelLoadingState(props: { label: string }) { - return ( -
-
-
- - -
-
-
- - - - - -
- {props.label} -
-
-
- ); -} diff --git a/apps/web/src/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/src/components/DiffWorkerPoolProvider.tsx deleted file mode 100644 index 5babd42..0000000 --- a/apps/web/src/src/components/DiffWorkerPoolProvider.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; -import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; -import { useEffect, useMemo, type ReactNode } from "react"; -import { useTheme } from "../hooks/useTheme"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; - -function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { - const workerPool = useWorkerPool(); - - useEffect(() => { - if (!workerPool) { - return; - } - - const current = workerPool.getDiffRenderOptions(); - if (current.theme === themeName) { - return; - } - - void workerPool - .setRenderOptions({ - ...current, - theme: themeName, - }) - .catch(() => undefined); - }, [themeName, workerPool]); - - return null; -} - -export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); - const workerPoolSize = useMemo(() => { - const cores = - typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); - return Math.max(2, Math.min(6, Math.floor(cores / 2))); - }, []); - - return ( - new DiffsWorker(), - poolSize: workerPoolSize, - totalASTLRUCacheSize: 240, - }} - highlighterOptions={{ - theme: diffThemeName, - tokenizeMaxLineLength: 1_000, - }} - > - - {children} - - ); -} diff --git a/apps/web/src/src/components/GitActionsControl.logic.test.ts b/apps/web/src/src/components/GitActionsControl.logic.test.ts deleted file mode 100644 index 44ad29e..0000000 --- a/apps/web/src/src/components/GitActionsControl.logic.test.ts +++ /dev/null @@ -1,1017 +0,0 @@ -import type { GitStatusResult } from "@t3tools/contracts"; -import { assert, describe, it } from "vitest"; -import { - buildGitActionProgressStages, - buildMenuItems, - requiresDefaultBranchConfirmation, - resolveAutoFeatureBranchName, - resolveDefaultBranchActionDialogCopy, - resolveQuickAction, - summarizeGitResult, -} from "./GitActionsControl.logic"; - -function status(overrides: Partial = {}): GitStatusResult { - return { - branch: "feature/test", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - ...overrides, - }; -} - -describe("when: branch is clean and has an open PR", () => { - it("resolveQuickAction opens the existing PR", () => { - const quick = resolveQuickAction( - status({ - pr: { - number: 10, - title: "Open PR", - url: "https://example.com/pr/10", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { kind: "open_pr", label: "View PR", disabled: false }); - }); - - it("buildMenuItems disables commit/push and enables open PR", () => { - const items = buildMenuItems( - status({ - pr: { - number: 11, - title: "Existing PR", - url: "https://example.com/pr/11", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "View PR", - disabled: false, - icon: "pr", - kind: "open_pr", - }, - ]); - }); -}); - -describe("when: actions are busy", () => { - it("resolveQuickAction returns running disabled state", () => { - const quick = resolveQuickAction(status(), true); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git action in progress.", - }); - }); - - it("buildMenuItems disables all actions", () => { - const items = buildMenuItems(status(), true); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: git status is unavailable", () => { - it("resolveQuickAction returns unavailable disabled state", () => { - const quick = resolveQuickAction(null, false); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git status is unavailable.", - }); - }); - - it("buildMenuItems returns no menu items", () => { - const items = buildMenuItems(null, false); - assert.deepEqual(items, []); - }); -}); - -describe("when: branch is clean, ahead, and has an open PR", () => { - it("resolveQuickAction prefers push", () => { - const quick = resolveQuickAction( - status({ - aheadCount: 3, - pr: { - number: 13, - title: "Open PR", - url: "https://example.com/pr/13", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" }); - }); - - it("buildMenuItems enables push and keeps open PR available", () => { - const items = buildMenuItems( - status({ - aheadCount: 2, - pr: { - number: 12, - title: "Existing PR", - url: "https://example.com/pr/12", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "View PR", - disabled: false, - icon: "pr", - kind: "open_pr", - }, - ]); - }); -}); - -describe("when: branch is clean, ahead, and has no open PR", () => { - it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Push & create PR", - }); - }); - - it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: branch is clean, up to date, and has no open PR", () => { - it("resolveQuickAction returns disabled no-action state", () => { - const quick = resolveQuickAction( - status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems disables commit, push, and create PR", () => { - const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: branch is behind upstream", () => { - it("resolveQuickAction returns pull", () => { - const quick = resolveQuickAction(status({ behindCount: 2 }), false); - assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); - }); - - it("buildMenuItems disables push and create PR", () => { - const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: branch has diverged from upstream", () => { - it("resolveQuickAction returns a disabled sync hint", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); - assert.deepEqual(quick, { - label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }); - }); -}); - -describe("when: working tree has local changes", () => { - it("resolveQuickAction returns commit, push, and create PR", () => { - const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("resolveQuickAction falls back to commit when no origin remote exists", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, hasUpstream: false }), - false, - false, - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit", - label: "Commit", - disabled: false, - }); - }); - - it("resolveQuickAction returns commit and push when open PR exists", () => { - const quick = resolveQuickAction( - status({ - hasWorkingTreeChanges: true, - pr: { - number: 16, - title: "Existing PR", - url: "https://example.com/pr/16", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Commit & push", - }); - }); - - it("buildMenuItems enables commit and disables push and PR", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: on default branch without open PR", () => { - it("resolveQuickAction returns commit and push when local changes exist", () => { - const quick = resolveQuickAction( - status({ branch: "main", hasWorkingTreeChanges: true }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Commit & push", - disabled: false, - }); - }); - - it("resolveQuickAction returns push when branch is ahead", () => { - const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, pr: null }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); -}); - -describe("when: working tree has local changes and branch is behind upstream", () => { - it("resolveQuickAction still prefers commit, push, and create PR", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, behindCount: 1 }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("buildMenuItems enables commit and keeps push and PR disabled", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: HEAD is detached and there are no local changes", () => { - it("resolveQuickAction shows detached head hint", () => { - const quick = resolveQuickAction( - status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: branch has no upstream configured", () => { - it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { - const quick = resolveQuickAction( - status({ hasUpstream: false, pr: null, aheadCount: 0 }), - false, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction opens PR when clean, no upstream, no local commits are ahead, and PR exists", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 0, - pr: { - number: 14, - title: "Existing PR", - url: "https://example.com/pr/14", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "open_pr", - label: "View PR", - disabled: false, - }); - }); - - it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 1, - pr: { - number: 15, - title: "Existing PR", - url: "https://example.com/pr/15", - baseBranch: "main", - headBranch: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems disables push and create PR when no commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 2, - pr: null, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Push & create PR", - disabled: false, - }); - }); - - it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 2, - pr: null, - }), - false, - false, - false, - ); - assert.deepEqual(quick, { - kind: "show_hint", - label: "Push", - hint: 'Add an "origin" remote before pushing or creating a PR.', - disabled: true, - }); - }); - - it("buildMenuItems enables create PR when no upstream and commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("buildMenuItems disables push and create PR when no origin remote exists", () => { - const items = buildMenuItems( - status({ hasUpstream: false, pr: null, aheadCount: 2 }), - false, - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { - const quick = resolveQuickAction( - status({ - branch: "main", - hasUpstream: false, - aheadCount: 0, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { - const quick = resolveQuickAction( - status({ - branch: "main", - hasUpstream: false, - aheadCount: 1, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems still disables push and create PR when branch is behind", () => { - const items = buildMenuItems( - status({ - hasUpstream: false, - behindCount: 1, - aheadCount: 0, - pr: null, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default branch", () => { - assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); - assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); - assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); - assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); - }); -}); - -describe("resolveDefaultBranchActionDialogCopy", () => { - it("uses push-only copy when pushing without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push to default branch?", - description: - 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', - continueLabel: "Push to main", - }); - }); - - it("uses push-and-pr copy when creating a PR without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push & create PR from default branch?", - description: - 'This action will push local commits and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', - continueLabel: "Push & create PR", - }); - }); - - it("keeps commit copy when the action includes a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: true, - }); - - assert.deepEqual(copy, { - title: "Commit, push & create PR from default branch?", - description: - 'This action will commit, push, and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', - continueLabel: "Commit, push & create PR", - }); - }); -}); - -describe("buildGitActionProgressStages", () => { - it("shows only push progress when push-only is forced", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); - }); - - it("skips commit stages for create-pr flow when push-only is forced", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]); - }); - - it("includes commit stages for commit+push when working tree is dirty", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, [ - "Generating commit message...", - "Committing...", - "Pushing to origin/feature/test...", - ]); - }); -}); - -describe("summarizeGitResult", () => { - it("returns commit-focused toast for commit action", () => { - const result = summarizeGitResult({ - action: "commit", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "0123456789abcdef", - subject: "feat: add optimistic UI for git action button", - }, - push: { status: "skipped_not_requested" }, - pr: { status: "skipped_not_requested" }, - }); - - assert.deepEqual(result, { - title: "Committed 0123456", - description: "feat: add optimistic UI for git action button", - }); - }); - - it("returns push-focused toast for push action", () => { - const result = summarizeGitResult({ - action: "commit_push", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "abcdef0123456789", - subject: "fix: tighten quick action tooltip hover handling", - }, - push: { - status: "pushed", - branch: "foo", - upstreamBranch: "origin/foo", - }, - pr: { status: "skipped_not_requested" }, - }); - - assert.deepEqual(result, { - title: "Pushed abcdef0 to origin/foo", - description: "fix: tighten quick action tooltip hover handling", - }); - }); - - it("returns PR-focused toast for created PR action", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: ship github shortcuts", - }, - push: { - status: "pushed", - branch: "foo", - }, - pr: { - status: "created", - number: 42, - title: "feat: ship github shortcuts and improve PR CTA in success toast", - }, - }); - - assert.deepEqual(result, { - title: "Created PR #42", - description: "feat: ship github shortcuts and improve PR CTA in success toast", - }); - }); - - it("truncates long description text", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "short subject", - }, - push: { status: "pushed", branch: "foo" }, - pr: { - status: "created", - number: 99, - title: - "feat: this title is intentionally extremely long so we can validate that toast descriptions are truncated with an ellipsis suffix", - }, - }); - - assert.deepEqual(result, { - title: "Created PR #99", - description: "feat: this title is intentionally extremely long so we can validate t...", - }); - }); -}); - -describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred branch names when available", () => { - const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(branch, "feature/fix-toast-copy"); - }); - - it("normalizes preferred names that already include a branch namespace", () => { - const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(branch, "feature/refine-toolbar-actions"); - }); - - it("increments suffix when the preferred branch name already exists", () => { - const branch = resolveAutoFeatureBranchName( - ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], - "fix toast copy", - ); - assert.equal(branch, "feature/fix-toast-copy-3"); - }); - - it("treats existing branch names as case-insensitive for collision checks", () => { - const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); - assert.equal(branch, "feature/ticket-1-2"); - }); - - it("falls back to feature/update when no preferred name is provided", () => { - const branch = resolveAutoFeatureBranchName(["main"]); - assert.equal(branch, "feature/update"); - }); -}); diff --git a/apps/web/src/src/components/GitActionsControl.logic.ts b/apps/web/src/src/components/GitActionsControl.logic.ts deleted file mode 100644 index 8f7f023..0000000 --- a/apps/web/src/src/components/GitActionsControl.logic.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { - GitRunStackedActionResult, - GitStackedAction, - GitStatusResult, -} from "@t3tools/contracts"; - -export type GitActionIconName = "commit" | "push" | "pr"; - -export type GitDialogAction = "commit" | "push" | "create_pr"; - -export interface GitActionMenuItem { - id: "commit" | "push" | "pr"; - label: string; - disabled: boolean; - icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; - dialogAction?: GitDialogAction; -} - -export interface GitQuickAction { - label: string; - disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; - action?: GitStackedAction; - hint?: string; -} - -export interface DefaultBranchActionDialogCopy { - title: string; - description: string; - continueLabel: string; -} - -export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; - -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; - -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} - -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - forcePushOnly?: boolean; - pushTarget?: string; - featureBranch?: boolean; -}): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const shouldIncludeCommitStages = - !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; -} - -const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - -export function summarizeGitResult(result: GitRunStackedActionResult): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - -export function buildMenuItems( - gitStatus: GitStatusResult | null, - isBusy: boolean, - hasOriginRemote = true, -): GitActionMenuItem[] { - if (!gitStatus) return []; - - const hasBranch = gitStatus.branch !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isBehind = gitStatus.behindCount > 0; - const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; - const canCommit = !isBusy && hasChanges; - const canPush = - !isBusy && - hasBranch && - !hasChanges && - !isBehind && - gitStatus.aheadCount > 0 && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canCreatePr = - !isBusy && - hasBranch && - !hasChanges && - !hasOpenPr && - gitStatus.aheadCount > 0 && - !isBehind && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canOpenPr = !isBusy && hasOpenPr; - - return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - hasOpenPr - ? { - id: "pr", - label: "View PR", - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: "Create PR", - disabled: !canCreatePr, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]; -} - -export function resolveQuickAction( - gitStatus: GitStatusResult | null, - isBusy: boolean, - isDefaultBranch = false, - hasOriginRemote = true, -): GitQuickAction { - if (isBusy) { - return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; - } - - if (!gitStatus) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Git status is unavailable.", - }; - } - - const hasBranch = gitStatus.branch !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - const isDiverged = isAhead && isBehind; - - if (!hasBranch) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", - }; - } - - if (hasChanges) { - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; - } - if (hasOpenPr || isDefaultBranch) { - return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; - } - return { - label: "Commit, push & PR", - disabled: false, - kind: "run_action", - action: "commit_push_pr", - }; - } - - if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { - if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; - } - return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', - }; - } - if (!isAhead) { - if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; - } - return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: "No local commits to push.", - }; - } - if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; - } - return { - label: "Push & create PR", - disabled: false, - kind: "run_action", - action: "commit_push_pr", - }; - } - - if (isDiverged) { - return { - label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }; - } - - if (isBehind) { - return { - label: "Pull", - disabled: false, - kind: "run_pull", - }; - } - - if (isAhead) { - if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; - } - return { - label: "Push & create PR", - disabled: false, - kind: "run_action", - action: "commit_push_pr", - }; - } - - if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; - } - - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Branch is up to date. No action needed.", - }; -} - -export function requiresDefaultBranchConfirmation( - action: GitStackedAction, - isDefaultBranch: boolean, -): boolean { - if (!isDefaultBranch) return false; - return action === "commit_push" || action === "commit_push_pr"; -} - -export function resolveDefaultBranchActionDialogCopy(input: { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; -}): DefaultBranchActionDialogCopy { - const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; - - if (input.action === "commit_push") { - if (input.includesCommit) { - return { - title: "Commit & push to default branch?", - description: `This action will commit and push changes${suffix}`, - continueLabel: `Commit & push to ${branchLabel}`, - }; - } - return { - title: "Push to default branch?", - description: `This action will push local commits${suffix}`, - continueLabel: `Push to ${branchLabel}`, - }; - } - - if (input.includesCommit) { - return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: `Commit, push & create PR`, - }; - } - return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", - }; -} - -// Re-export from shared for backwards compatibility in this module's exports -export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/src/components/GitActionsControl.tsx b/apps/web/src/src/components/GitActionsControl.tsx deleted file mode 100644 index 0771875..0000000 --- a/apps/web/src/src/components/GitActionsControl.tsx +++ /dev/null @@ -1,962 +0,0 @@ -import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; -import { GitHubIcon } from "./Icons"; -import { - buildGitActionProgressStages, - buildMenuItems, - type GitActionIconName, - type GitActionMenuItem, - type GitQuickAction, - type DefaultBranchConfirmableAction, - requiresDefaultBranchConfirmation, - resolveDefaultBranchActionDialogCopy, - resolveQuickAction, - summarizeGitResult, -} from "./GitActionsControl.logic"; -import { useAppSettings } from "~/appSettings"; -import { Button } from "~/components/ui/button"; -import { Checkbox } from "~/components/ui/checkbox"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "~/components/ui/dialog"; -import { Group, GroupSeparator } from "~/components/ui/group"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { Textarea } from "~/components/ui/textarea"; -import { toastManager } from "~/components/ui/toast"; -import { openInPreferredEditor } from "~/editorPreferences"; -import { - gitBranchesQueryOptions, - gitInitMutationOptions, - gitMutationKeys, - gitPullMutationOptions, - gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitQueries, -} from "~/lib/gitReactQuery"; -import { resolvePathLinkTarget } from "~/terminal-links"; -import { readNativeApi } from "~/nativeApi"; - -interface GitActionsControlProps { - gitCwd: string | null; - activeThreadId: ThreadId | null; -} - -interface PendingDefaultBranchAction { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; - commitMessage?: string; - forcePushOnlyProgress: boolean; - onConfirmed?: () => void; - filePaths?: string[]; -} - -type GitActionToastId = ReturnType; - -function getMenuActionDisabledReason({ - item, - gitStatus, - isBusy, - hasOriginRemote, -}: { - item: GitActionMenuItem; - gitStatus: GitStatusResult | null; - isBusy: boolean; - hasOriginRemote: boolean; -}): string | null { - if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; - - const hasBranch = gitStatus.branch !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - - if (item.id === "commit") { - if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; - } - return "Commit is currently unavailable."; - } - - if (item.id === "push") { - if (!hasBranch) { - return "Detached HEAD: checkout a branch before pushing."; - } - if (hasChanges) { - return "Commit or stash local changes before pushing."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; - } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before pushing.'; - } - if (!isAhead) { - return "No local commits to push."; - } - return "Push is currently unavailable."; - } - - if (hasOpenPr) { - return "View PR is currently unavailable."; - } - if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; - } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; - } - if (!isAhead) { - return "No local commits to include in a PR."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; - } - return "Create PR is currently unavailable."; -} - -const COMMIT_DIALOG_TITLE = "Commit changes"; -const COMMIT_DIALOG_DESCRIPTION = - "Review and confirm your commit. Leave the message blank to auto-generate one."; - -function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { - if (icon === "commit") return ; - if (icon === "push") return ; - return ; -} - -function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { - const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; - if (quickAction.kind === "run_pull") return ; - if (quickAction.kind === "run_action") { - if (quickAction.action === "commit") return ; - if (quickAction.action === "commit_push") return ; - return ; - } - if (quickAction.label === "Commit") return ; - return ; -} - -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { - const { settings } = useAppSettings(); - const threadToastData = useMemo( - () => (activeThreadId ? { threadId: activeThreadId } : undefined), - [activeThreadId], - ); - const queryClient = useQueryClient(); - const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); - const [dialogCommitMessage, setDialogCommitMessage] = useState(""); - const [excludedFiles, setExcludedFiles] = useState>(new Set()); - const [isEditingFiles, setIsEditingFiles] = useState(false); - const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = - useState(null); - - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); - - const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); - // Default to true while loading so we don't flash init controls. - const isRepo = branchList?.isRepo ?? true; - const hasOriginRemote = branchList?.hasOriginRemote ?? false; - const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; - const isGitStatusOutOfSync = - !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; - - useEffect(() => { - if (!isGitStatusOutOfSync) return; - void invalidateGitQueries(queryClient); - }, [isGitStatusOutOfSync, queryClient]); - - const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; - - const allFiles = gitStatusForActions?.workingTree.files ?? []; - const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); - const allSelected = excludedFiles.size === 0; - const noneSelected = selectedFiles.length === 0; - - const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); - - const runImmediateGitActionMutation = useMutation( - gitRunStackedActionMutationOptions({ - cwd: gitCwd, - queryClient, - model: settings.textGenerationModel ?? null, - }), - ); - const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); - - const isRunStackedActionRunning = - useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning; - const isDefaultBranch = useMemo(() => { - const branchName = gitStatusForActions?.branch; - if (!branchName) return false; - const current = branchList?.branches.find((branch) => branch.name === branchName); - return current?.isDefault ?? (branchName === "main" || branchName === "master"); - }, [branchList?.branches, gitStatusForActions?.branch]); - - const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], - ); - const quickAction = useMemo( - () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], - ); - const quickActionDisabledReason = quickAction.disabled - ? (quickAction.hint ?? "This action is currently unavailable.") - : null; - const pendingDefaultBranchActionCopy = pendingDefaultBranchAction - ? resolveDefaultBranchActionDialogCopy({ - action: pendingDefaultBranchAction.action, - branchName: pendingDefaultBranchAction.branchName, - includesCommit: pendingDefaultBranchAction.includesCommit, - }) - : null; - - const openExistingPr = useCallback(async () => { - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; - if (!prUrl) { - toastManager.add({ - type: "error", - title: "No open PR found.", - data: threadToastData, - }); - return; - } - void api.shell.openExternal(prUrl).catch((err) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }); - }); - }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); - - const runGitActionWithToast = useCallback( - async ({ - action, - commitMessage, - forcePushOnlyProgress = false, - onConfirmed, - skipDefaultBranchPrompt = false, - statusOverride, - featureBranch = false, - isDefaultBranchOverride, - progressToastId, - filePaths, - }: { - action: GitStackedAction; - commitMessage?: string; - forcePushOnlyProgress?: boolean; - onConfirmed?: () => void; - skipDefaultBranchPrompt?: boolean; - statusOverride?: GitStatusResult | null; - featureBranch?: boolean; - isDefaultBranchOverride?: boolean; - progressToastId?: GitActionToastId; - filePaths?: string[]; - }) => { - const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = - isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); - const includesCommit = - !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); - if ( - !skipDefaultBranchPrompt && - requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && - actionBranch - ) { - if (action !== "commit_push" && action !== "commit_push_pr") { - return; - } - setPendingDefaultBranchAction({ - action, - branchName: actionBranch, - includesCommit, - ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - }); - return; - } - onConfirmed?.(); - - const progressStages = buildGitActionProgressStages({ - action, - hasCustomCommitMessage: !!commitMessage?.trim(), - hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, - forcePushOnly: forcePushOnlyProgress, - featureBranch, - }); - const resolvedProgressToastId = - progressToastId ?? - toastManager.add({ - type: "loading", - title: progressStages[0] ?? "Running git action...", - timeout: 0, - data: threadToastData, - }); - - if (progressToastId) { - toastManager.update(progressToastId, { - type: "loading", - title: progressStages[0] ?? "Running git action...", - timeout: 0, - data: threadToastData, - }); - } - - let stageIndex = 0; - const stageInterval = setInterval(() => { - stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); - toastManager.update(resolvedProgressToastId, { - title: progressStages[stageIndex] ?? "Running git action...", - type: "loading", - timeout: 0, - data: threadToastData, - }); - }, 1100); - - const stopProgressUpdates = () => { - clearInterval(stageInterval); - }; - - const promise = runImmediateGitActionMutation.mutateAsync({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), - }); - - try { - const result = await promise; - stopProgressUpdates(); - const resultToast = summarizeGitResult(result); - - const existingOpenPrUrl = - actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; - const prUrl = result.pr.url ?? existingOpenPrUrl; - const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; - const shouldOfferOpenPrCta = - (action === "commit_push" || action === "commit_push_pr") && - !!prUrl && - (!actionIsDefaultBranch || - result.pr.status === "created" || - result.pr.status === "opened_existing"); - const shouldOfferCreatePrCta = - action === "commit_push" && - !prUrl && - result.push.status === "pushed" && - !actionIsDefaultBranch; - const closeResultToast = () => { - toastManager.close(resolvedProgressToastId); - }; - - toastManager.update(resolvedProgressToastId, { - type: "success", - title: resultToast.title, - description: resultToast.description, - timeout: 0, - data: { - ...threadToastData, - dismissAfterVisibleMs: 10_000, - }, - ...(shouldOfferPushCta - ? { - actionProps: { - children: "Push", - onClick: () => { - void runGitActionWithToast({ - action: "commit_push", - forcePushOnlyProgress: true, - onConfirmed: closeResultToast, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : shouldOfferOpenPrCta - ? { - actionProps: { - children: "View PR", - onClick: () => { - const api = readNativeApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(prUrl); - }, - }, - } - : shouldOfferCreatePrCta - ? { - actionProps: { - children: "Create PR", - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: "commit_push_pr", - forcePushOnlyProgress: true, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : {}), - }); - } catch (err) { - stopProgressUpdates(); - toastManager.update(resolvedProgressToastId, { - type: "error", - title: "Action failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }); - } - }, - - [ - isDefaultBranch, - runImmediateGitActionMutation, - setPendingDefaultBranchAction, - threadToastData, - gitStatusForActions, - ], - ); - - const continuePendingDefaultBranchAction = useCallback(() => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - skipDefaultBranchPrompt: true, - }); - }, [pendingDefaultBranchAction, runGitActionWithToast]); - - const checkoutNewBranchAndRunAction = useCallback( - (actionParams: { - action: GitStackedAction; - commitMessage?: string; - forcePushOnlyProgress?: boolean; - onConfirmed?: () => void; - filePaths?: string[]; - }) => { - void runGitActionWithToast({ - ...actionParams, - featureBranch: true, - skipDefaultBranchPrompt: true, - }); - }, - [runGitActionWithToast], - ); - - const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - checkoutNewBranchAndRunAction({ - action, - ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - }); - }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); - - const runDialogActionOnNewBranch = useCallback(() => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); - - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - - checkoutNewBranchAndRunAction({ - action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - }); - }, [ - allSelected, - isCommitDialogOpen, - dialogCommitMessage, - checkoutNewBranchAndRunAction, - selectedFiles, - ]); - - const runQuickAction = useCallback(() => { - if (quickAction.kind === "open_pr") { - void openExistingPr(); - return; - } - if (quickAction.kind === "run_pull") { - const promise = pullMutation.mutateAsync(); - toastManager.promise(promise, { - loading: { title: "Pulling...", data: threadToastData }, - success: (result) => ({ - title: result.status === "pulled" ? "Pulled" : "Already up to date", - description: - result.status === "pulled" - ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` - : `${result.branch} is already synchronized.`, - data: threadToastData, - }), - error: (err) => ({ - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), - }); - void promise.catch(() => undefined); - return; - } - if (quickAction.kind === "show_hint") { - toastManager.add({ - type: "info", - title: quickAction.label, - description: quickAction.hint, - data: threadToastData, - }); - return; - } - if (quickAction.action) { - void runGitActionWithToast({ action: quickAction.action }); - } - }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast, threadToastData]); - - const openDialogForMenuItem = useCallback( - (item: GitActionMenuItem) => { - if (item.disabled) return; - if (item.kind === "open_pr") { - void openExistingPr(); - return; - } - if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true }); - return; - } - if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "commit_push_pr" }); - return; - } - setExcludedFiles(new Set()); - setIsEditingFiles(false); - setIsCommitDialogOpen(true); - }, - [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen], - ); - - const runDialogAction = useCallback(() => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - void runGitActionWithToast({ - action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - }); - }, [ - allSelected, - dialogCommitMessage, - isCommitDialogOpen, - runGitActionWithToast, - selectedFiles, - setDialogCommitMessage, - setIsCommitDialogOpen, - ]); - - const openChangedFileInEditor = useCallback( - (filePath: string) => { - const api = readNativeApi(); - if (!api || !gitCwd) { - toastManager.add({ - type: "error", - title: "Editor opening is unavailable.", - data: threadToastData, - }); - return; - } - const target = resolvePathLinkTarget(filePath, gitCwd); - void openInPreferredEditor(api, target).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - data: threadToastData, - }); - }); - }, - [gitCwd, threadToastData], - ); - - if (!gitCwd) return null; - - return ( - <> - {!isRepo ? ( - - ) : ( - - {quickActionDisabledReason ? ( - - - } - > - - - {quickAction.label} - - - - {quickActionDisabledReason} - - - ) : ( - - )} - - { - if (open) void invalidateGitQueries(queryClient); - }} - > - } - disabled={isGitActionRunning} - > - - - {gitActionMenuItems.map((item) => { - const disabledReason = getMenuActionDisabledReason({ - item, - gitStatus: gitStatusForActions, - isBusy: isGitActionRunning, - hasOriginRemote, - }); - if (item.disabled && disabledReason) { - return ( - - } - > - - - {item.label} - - - - {disabledReason} - - - ); - } - - return ( - { - openDialogForMenuItem(item); - }} - > - - {item.label} - - ); - })} - {gitStatusForActions?.branch === null && ( -

- Detached HEAD: create and checkout a branch to enable push and PR actions. -

- )} - {gitStatusForActions && - gitStatusForActions.branch !== null && - !gitStatusForActions.hasWorkingTreeChanges && - gitStatusForActions.behindCount > 0 && - gitStatusForActions.aheadCount === 0 && ( -

- Behind upstream. Pull/rebase first. -

- )} - {isGitStatusOutOfSync && ( -

- Refreshing git status... -

- )} - {gitStatusError && ( -

{gitStatusError.message}

- )} -
-
-
- )} - - { - if (!open) { - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - } - }} - > - - - {COMMIT_DIALOG_TITLE} - {COMMIT_DIALOG_DESCRIPTION} - - -
-
- Branch - - - {gitStatusForActions?.branch ?? "(detached HEAD)"} - - {isDefaultBranch && ( - Warning: default branch - )} - -
-
-
-
- {isEditingFiles && allFiles.length > 0 && ( - { - setExcludedFiles( - allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), - ); - }} - /> - )} - Files - {!allSelected && !isEditingFiles && ( - - ({selectedFiles.length} of {allFiles.length}) - - )} -
- {allFiles.length > 0 && ( - - )} -
- {!gitStatusForActions || allFiles.length === 0 ? ( -

none

- ) : ( -
- -
- {allFiles.map((file) => { - const isExcluded = excludedFiles.has(file.path); - return ( -
- {isEditingFiles && ( - { - setExcludedFiles((prev) => { - const next = new Set(prev); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} - /> - )} - -
- ); - })} -
-
-
- - +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} - - / - - -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)} - -
-
- )} -
-
-
-

Commit message (optional)

-