From c3aea6a8075d2aae931e9f4a08a3971ceb08eaf0 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 3 Jun 2026 02:38:34 -0700 Subject: [PATCH 1/2] feat(quick-composer): add global overlay window for quick compose - Add frameless quick-composer BrowserWindow with Ctrl/Cmd+L global shortcut - Wire overlay IPC (expand, close, thread sync, open thread in main window) - Expose windowKind on bridge; share rendererArgs and showAndFocusWindow - Render QuickComposerOverlay with transparent shell styles for overlay windows - Extract threadViewHandlers, useAgentStatusHydration, and startThreadFromDraft - Refactor MainView, AppContent, and ThreadPane to use shared handlers - Skip overlay app-store persistence; gate hydration runtime ownership; update tests --- src/main/ipc/localHandlers.ts | 7 +- src/main/main.ts | 109 +++++++- src/main/preload.ts | 38 +++ src/main/tray.ts | 12 +- src/main/window/createMainWindow.ts | 13 +- src/main/window/createQuickComposerWindow.ts | 121 +++++++++ src/main/window/rendererArgs.ts | 36 +++ src/main/window/showAndFocusWindow.ts | 17 ++ src/renderer/actions/threadActions.ts | 216 +++++++++++++++- src/renderer/app.test.tsx | 8 + src/renderer/app.tsx | 41 ++- src/renderer/bridge.ts | 4 + .../components/thread/threadViewHandlers.ts | 142 +++++++++++ src/renderer/hooks/useAgentStatusHydration.ts | 51 ++++ src/renderer/hooks/useAppHydration.ts | 15 +- src/renderer/main.tsx | 2 + src/renderer/state/dbStorage.test.ts | 1 + src/renderer/state/dbStorage.ts | 7 +- src/renderer/styles.css | 120 +++++++++ src/renderer/views/MainView/MainView.tsx | 52 +--- .../MainView/parts/AppContent/AppContent.tsx | 209 +-------------- .../parts/AppContent/parts/ThreadPane.tsx | 124 +-------- .../QuickComposerOverlay.tsx | 237 ++++++++++++++++++ src/shared/ipc/bridge.ts | 18 ++ src/shared/ipc/index.ts | 2 + 25 files changed, 1182 insertions(+), 420 deletions(-) create mode 100644 src/main/window/createQuickComposerWindow.ts create mode 100644 src/main/window/rendererArgs.ts create mode 100644 src/main/window/showAndFocusWindow.ts create mode 100644 src/renderer/components/thread/threadViewHandlers.ts create mode 100644 src/renderer/hooks/useAgentStatusHydration.ts create mode 100644 src/renderer/views/QuickComposerOverlay/QuickComposerOverlay.tsx diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index ed3a6b15..2343a637 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os"; import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "electron"; import type { BrowserPanelManager } from "../browser"; import { openMicrophoneSettings } from "../browser/permissions"; +import { showAndFocusWindow } from "../window/showAndFocusWindow"; import { dbDeleteProject, dbDeleteThread, @@ -116,11 +117,7 @@ export function createLocalIpcHandlers( openMicrophoneSettings: () => openMicrophoneSettings(), focusWindow: () => { const win = options.getMainWindow(); - if (!win) return; - if (win.isMinimized()) { - win.restore(); - } - win.focus(); + if (win) showAndFocusWindow(win); }, getHomeScopeLocation: () => process.platform === "win32" diff --git a/src/main/main.ts b/src/main/main.ts index 73ef7f96..1ee3b830 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,7 @@ import { watch } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { app, BrowserWindow, Menu, nativeTheme } from "electron"; +import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeTheme } from "electron"; import { resolveThemeMode } from "@/shared/themeMode"; import { closeDatabase, dbGetThreads, initDatabase } from "./db"; import { cleanupOrphanedAttachments, prepareLightcodeDataRoot } from "./lightcodeData"; @@ -21,12 +21,17 @@ import { import { SupervisorClient } from "./supervisor/SupervisorClient"; import { createAutoUpdaterController } from "./updates/autoUpdater"; import { createMainWindow } from "./window/createMainWindow"; +import { + createQuickComposerWindow, + setQuickComposerWindowExpanded, +} from "./window/createQuickComposerWindow"; +import { showAndFocusWindow } from "./window/showAndFocusWindow"; import { createTray, type TrayHandle } from "./tray"; import type { SupervisorEvent } from "@/shared/ipc"; import { type LightcodePaths, resolveLightcodeBaseDir } from "@/shared/lightcodePaths"; import { getAppName } from "@/shared/appName"; import { resolveLightcodeChannel } from "@/shared/channel"; -import { IPC_EVENT_CHANNELS } from "@/shared/ipc"; +import { IPC_EVENT_CHANNELS, IPC_WINDOW_CHANNELS } from "@/shared/ipc"; import { readSharedSettingsFile } from "./sharedSettingsFile"; import { WindowsJobObjectManager } from "./windowsJobObject"; import { captureMainException, initializeMainSentry } from "./diagnostics/sentry"; @@ -55,8 +60,10 @@ const posthogEnableDev = process.env.POSTHOG_ENABLE_DEV === "1"; const hasSingleInstanceLock = isDev || app.requestSingleInstanceLock(); const WINDOW_CHROME_HEIGHT = 32; +const QUICK_COMPOSER_SHORTCUT = "CommandOrControl+L"; let mainWindow: BrowserWindow | null = null; +const quickComposerWindows = new Set(); let lightcodePaths: LightcodePaths | null = null; let windowsJobObjectManager: WindowsJobObjectManager | null = null; let browserPanelManager: BrowserPanelManager | null = null; @@ -110,6 +117,51 @@ function handleMainWindowClose(event: Electron.Event): void { mainWindow.hide(); } +function focusMainWindow(): void { + if (mainWindow) showAndFocusWindow(mainWindow); +} + +function quickComposerWindowFor(event: Electron.IpcMainInvokeEvent): BrowserWindow | null { + const window = BrowserWindow.fromWebContents(event.sender); + return window && quickComposerWindows.has(window) && !window.isDestroyed() ? window : null; +} + +function sendSupervisorEventToRenderers(event: SupervisorEvent): void { + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); + for (const window of quickComposerWindows) { + if (!window.isDestroyed()) { + window.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); + } + } +} + +function openQuickComposerWindow(): void { + const window = createQuickComposerWindow({ + title: getAppName(channel, isDev), + isDev, + channel, + preloadPath: join(__dirname, "preload.cjs"), + rendererHtmlPath: join(__dirname, "../renderer/index.html"), + appVersion: app.getVersion(), + posthogEnableDev, + posthogEnabled, + posthogHost, + posthogKey, + sentryEnabled, + ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), + onClosed: () => { + quickComposerWindows.delete(window); + }, + onRendererProcessGone: (details) => { + captureMainException(new Error(`Quick composer renderer process gone: ${details.reason}`), { + "lightcode.feature_area": "quick-composer", + "lightcode.process": "renderer", + }); + }, + }); + quickComposerWindows.add(window); +} + const workingThreads = new Set(); const sleepInhibitor = createSleepInhibitor(); @@ -151,16 +203,7 @@ if (!hasSingleInstanceLock) { app.quit(); } else { app.on("second-instance", () => { - if (!mainWindow) { - return; - } - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - mainWindow.focus(); + focusMainWindow(); }); app.whenReady().then(async () => { @@ -222,7 +265,7 @@ if (!hasSingleInstanceLock) { }, onEvent: (event) => { handleSupervisorEventForSleep(event); - mainWindow?.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); + sendSupervisorEventToRenderers(event); }, onReset: () => { workingThreads.clear(); @@ -263,6 +306,35 @@ if (!hasSingleInstanceLock) { callSupervisor: (name, payload) => supervisorClient.call(name, payload), }); + ipcMain.handle(IPC_WINDOW_CHANNELS.quickOverlaySetExpanded, (event, expanded: unknown) => { + const window = quickComposerWindowFor(event); + if (!window) return; + setQuickComposerWindowExpanded(window, expanded === true); + }); + ipcMain.handle(IPC_WINDOW_CHANNELS.quickOverlayClose, (event) => { + const window = quickComposerWindowFor(event); + if (!window) return; + window.close(); + }); + ipcMain.handle(IPC_WINDOW_CHANNELS.quickOverlayThreadChanged, (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.length === 0) return; + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.externalAppStoreChanged, { threadId }); + }); + ipcMain.handle( + IPC_WINDOW_CHANNELS.quickOverlayOpenThreadInMainWindow, + (event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.length === 0) return; + focusMainWindow(); + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.openThreadInMainWindow, { threadId }); + const window = quickComposerWindowFor(event); + if (!window) return; + setQuickComposerWindowExpanded(window, false); + setTimeout(() => { + if (!window.isDestroyed()) window.close(); + }, 160); + }, + ); + mainWindow = createMainWindow({ title: getAppName(channel, isDev), isDev, @@ -302,6 +374,10 @@ if (!hasSingleInstanceLock) { }, }); + if (!globalShortcut.register(QUICK_COMPOSER_SHORTCUT, openQuickComposerWindow)) { + console.warn(`[lightcode] failed to register ${QUICK_COMPOSER_SHORTCUT} for quick composer`); + } + await jobObjectReady; const hookDebugOn = @@ -384,6 +460,7 @@ if (!hasSingleInstanceLock) { app.on("before-quit", () => { isQuitting = true; + globalShortcut.unregister(QUICK_COMPOSER_SHORTCUT); supervisorClient.dispose(); windowsJobObjectManager?.dispose(); windowsJobObjectManager = null; @@ -394,6 +471,12 @@ if (!hasSingleInstanceLock) { sleepInhibitor.dispose(); tray?.destroy(); tray = null; + for (const window of quickComposerWindows) { + if (!window.isDestroyed()) { + window.close(); + } + } + quickComposerWindows.clear(); }); }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 13c63d7c..06912cb3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -3,8 +3,10 @@ import { type LightcodeChannel, normalizeChannel } from "@/shared/channel"; import { createInvokeBridge, IPC_EVENT_CHANNELS, + IPC_WINDOW_CHANNELS, type BrowserEvent, type LightcodeBridge, + type LightcodeWindowKind, type SupervisorEvent, type UpdateStatus, } from "@/shared/ipc"; @@ -44,6 +46,11 @@ function resolveChannel(): LightcodeChannel { return "stable"; } +function resolveWindowKind(): LightcodeWindowKind { + const value = resolveArgValue("--lc-window-kind="); + return value === "quickOverlay" ? "quickOverlay" : "main"; +} + function resolveSentryEnabled(): boolean { const prefix = "--lc-sentry-enabled="; for (const arg of process.argv) { @@ -73,6 +80,7 @@ function resolveArgBoolean(prefix: string): boolean { } const bridge: LightcodeBridge = { + windowKind: resolveWindowKind(), platform: process.platform, appVersion: resolveAppVersion(), arch: process.arch, @@ -117,6 +125,36 @@ const bridge: LightcodeBridge = { ipcRenderer.removeListener(IPC_EVENT_CHANNELS.browserEvent, handler); }; }, + setQuickOverlayExpanded(expanded) { + return ipcRenderer.invoke(IPC_WINDOW_CHANNELS.quickOverlaySetExpanded, expanded); + }, + closeQuickOverlay() { + return ipcRenderer.invoke(IPC_WINDOW_CHANNELS.quickOverlayClose); + }, + notifyQuickOverlayThreadChanged(threadId) { + return ipcRenderer.invoke(IPC_WINDOW_CHANNELS.quickOverlayThreadChanged, threadId); + }, + openQuickOverlayThreadInMainWindow(threadId) { + return ipcRenderer.invoke(IPC_WINDOW_CHANNELS.quickOverlayOpenThreadInMainWindow, threadId); + }, + onExternalAppStoreChanged(listener) { + const handler = (_event: Electron.IpcRendererEvent, payload: { threadId?: string }) => { + listener(payload); + }; + ipcRenderer.on(IPC_EVENT_CHANNELS.externalAppStoreChanged, handler); + return () => { + ipcRenderer.removeListener(IPC_EVENT_CHANNELS.externalAppStoreChanged, handler); + }; + }, + onOpenThreadInMainWindow(listener) { + const handler = (_event: Electron.IpcRendererEvent, payload: { threadId: string }) => { + listener(payload); + }; + ipcRenderer.on(IPC_EVENT_CHANNELS.openThreadInMainWindow, handler); + return () => { + ipcRenderer.removeListener(IPC_EVENT_CHANNELS.openThreadInMainWindow, handler); + }; + }, }; contextBridge.exposeInMainWorld("lightcode", bridge); diff --git a/src/main/tray.ts b/src/main/tray.ts index 20f6cb3b..5249c3e1 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { existsSync } from "node:fs"; import { Menu, Tray, app, nativeImage, type BrowserWindow } from "electron"; import type { LightcodeChannel } from "@/shared/channel"; +import { showAndFocusWindow } from "./window/showAndFocusWindow"; interface CreateTrayOptions { window: BrowserWindow; @@ -47,16 +48,7 @@ export function createTray(options: CreateTrayOptions): TrayHandle { const tray = new Tray(trayImage); tray.setToolTip(appName); - const showWindow = () => { - if (window.isDestroyed()) return; - if (window.isMinimized()) { - window.restore(); - } - if (!window.isVisible()) { - window.show(); - } - window.focus(); - }; + const showWindow = () => showAndFocusWindow(window); const rebuildMenu = () => { const menu = Menu.buildFromTemplate([ diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index e481db68..a04289ba 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -2,6 +2,7 @@ import { dbGetState, dbSetState } from "../db"; import { BrowserWindow, screen, type RenderProcessGoneDetails } from "electron"; import type { LightcodeChannel } from "@/shared/channel"; import { installSessionPermissions } from "../browser/permissions"; +import { buildRendererArgs } from "./rendererArgs"; interface WindowBounds { x?: number; @@ -104,17 +105,7 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo nodeIntegration: false, sandbox: true, webviewTag: true, - additionalArguments: [ - `--lc-app-version=${encodeURIComponent(options.appVersion)}`, - `--lc-is-dev=${options.isDev ? "1" : "0"}`, - `--lc-channel=${options.channel}`, - `--lc-posthog-enable-dev=${options.posthogEnableDev ? "1" : "0"}`, - `--lc-posthog-enabled=${options.posthogEnabled ? "1" : "0"}`, - // PostHog project keys are browser/client keys, not secrets; the renderer must send them. - `--lc-posthog-host=${encodeURIComponent(options.posthogHost)}`, - `--lc-posthog-key=${encodeURIComponent(options.posthogKey)}`, - `--lc-sentry-enabled=${options.sentryEnabled ? "1" : "0"}`, - ], + additionalArguments: buildRendererArgs("main", options), }, }); installSessionPermissions(window.webContents.session); diff --git a/src/main/window/createQuickComposerWindow.ts b/src/main/window/createQuickComposerWindow.ts new file mode 100644 index 00000000..f1d721aa --- /dev/null +++ b/src/main/window/createQuickComposerWindow.ts @@ -0,0 +1,121 @@ +import { BrowserWindow, screen, type RenderProcessGoneDetails } from "electron"; +import type { LightcodeChannel } from "@/shared/channel"; +import { installSessionPermissions } from "../browser/permissions"; +import { buildRendererArgs } from "./rendererArgs"; + +const COLLAPSED_WIDTH = 720; +const COLLAPSED_HEIGHT = 236; +const EXPANDED_WIDTH = 920; +const EXPANDED_HEIGHT = 700; + +export interface CreateQuickComposerWindowOptions { + title: string; + isDev: boolean; + channel: LightcodeChannel; + preloadPath: string; + rendererHtmlPath: string; + appVersion: string; + posthogEnableDev: boolean; + posthogEnabled: boolean; + posthogHost: string; + posthogKey: string; + sentryEnabled: boolean; + onClosed(): void; + onRendererProcessGone?: (details: RenderProcessGoneDetails) => void; + devServerUrl?: string; +} + +export function createQuickComposerWindow( + options: CreateQuickComposerWindowOptions, +): BrowserWindow { + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const { x, y, width, height } = display.workArea; + const window = new BrowserWindow({ + title: `${options.title} Quick Composer`, + show: false, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + width: COLLAPSED_WIDTH, + height: COLLAPSED_HEIGHT, + x: Math.round(x + (width - COLLAPSED_WIDTH) / 2), + y: Math.round(y + Math.max(24, height * 0.16)), + backgroundColor: "#00000000", + hasShadow: true, + autoHideMenuBar: true, + webPreferences: { + preload: options.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: true, + additionalArguments: buildRendererArgs("quickOverlay", options), + }, + }); + installSessionPermissions(window.webContents.session); + + window.once("ready-to-show", () => { + window.show(); + window.focus(); + }); + + const loadRenderer = () => { + if (options.isDev) { + void window.loadURL(options.devServerUrl as string); + } else { + void window.loadFile(options.rendererHtmlPath); + } + }; + + loadRenderer(); + + let lastReloadAt = 0; + let reloadCount = 0; + window.webContents.on("render-process-gone", (_event, details) => { + if (details.reason === "clean-exit" || window.isDestroyed()) { + return; + } + console.error( + `[lightcode] quick composer renderer gone: reason=${details.reason} exitCode=${details.exitCode}`, + ); + options.onRendererProcessGone?.(details); + const now = Date.now(); + if (now - lastReloadAt < 5_000) { + reloadCount += 1; + } else { + reloadCount = 1; + } + lastReloadAt = now; + if (reloadCount > 3) { + console.error("[lightcode] quick composer renderer gone too many times, not reloading"); + return; + } + loadRenderer(); + }); + + window.on("closed", options.onClosed); + + return window; +} + +export function setQuickComposerWindowExpanded(window: BrowserWindow, expanded: boolean): void { + if (window.isDestroyed()) return; + const targetWidth = expanded ? EXPANDED_WIDTH : COLLAPSED_WIDTH; + const targetHeight = expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT; + const bounds = window.getBounds(); + window.setBounds( + { + x: Math.round(bounds.x + (bounds.width - targetWidth) / 2), + y: bounds.y, + width: targetWidth, + height: targetHeight, + }, + true, + ); +} diff --git a/src/main/window/rendererArgs.ts b/src/main/window/rendererArgs.ts new file mode 100644 index 00000000..f583db08 --- /dev/null +++ b/src/main/window/rendererArgs.ts @@ -0,0 +1,36 @@ +import type { LightcodeChannel } from "@/shared/channel"; +import type { LightcodeWindowKind } from "@/shared/ipc"; + +export interface RendererArgOptions { + appVersion: string; + isDev: boolean; + channel: LightcodeChannel; + posthogEnableDev: boolean; + posthogEnabled: boolean; + posthogHost: string; + posthogKey: string; + sentryEnabled: boolean; +} + +/** + * Startup `additionalArguments` passed to every renderer window. The preload + * reads these synchronously to populate the bridge before any IPC, so the main + * and quick-composer windows must stay in lockstep here. + */ +export function buildRendererArgs( + windowKind: LightcodeWindowKind, + options: RendererArgOptions, +): string[] { + return [ + `--lc-window-kind=${windowKind}`, + `--lc-app-version=${encodeURIComponent(options.appVersion)}`, + `--lc-is-dev=${options.isDev ? "1" : "0"}`, + `--lc-channel=${options.channel}`, + `--lc-posthog-enable-dev=${options.posthogEnableDev ? "1" : "0"}`, + `--lc-posthog-enabled=${options.posthogEnabled ? "1" : "0"}`, + // PostHog project keys are browser/client keys, not secrets; the renderer must send them. + `--lc-posthog-host=${encodeURIComponent(options.posthogHost)}`, + `--lc-posthog-key=${encodeURIComponent(options.posthogKey)}`, + `--lc-sentry-enabled=${options.sentryEnabled ? "1" : "0"}`, + ]; +} diff --git a/src/main/window/showAndFocusWindow.ts b/src/main/window/showAndFocusWindow.ts new file mode 100644 index 00000000..77c4a88a --- /dev/null +++ b/src/main/window/showAndFocusWindow.ts @@ -0,0 +1,17 @@ +import type { BrowserWindow } from "electron"; + +/** + * Bring a window to the foreground: un-minimize, un-hide, then focus. Shared by + * the second-instance handler, the `focusWindow` IPC, and the tray menu so the + * restore/show/focus sequence stays consistent. + */ +export function showAndFocusWindow(win: BrowserWindow): void { + if (win.isDestroyed()) return; + if (win.isMinimized()) { + win.restore(); + } + if (!win.isVisible()) { + win.show(); + } + win.focus(); +} diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index a5afc22c..cf3dba14 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -1,9 +1,22 @@ import { startTransition } from "react"; +import type { + AgentStatus, + Project, + PromptSegment, + Thread, + ThreadConfig, + ThreadPresentationMode, +} from "@/shared/contracts"; +import { getProjectAgentStatuses } from "@/shared/agentStatus"; import { isHomeProject } from "@/shared/homeScope"; import { isDraftPaneId } from "@/shared/paneId"; +import { buildWorktreeLocation } from "@/shared/worktree"; import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; -import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; +import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; +import { useDevTerminalStore, type DevTerminalTab } from "@/renderer/state/devTerminalStore"; +import { refreshGitProject } from "@/renderer/state/gitRefresh"; +import { useGitStore } from "@/renderer/state/gitStore"; import { hasHydratedThreadRuntimeItems, hydrateThreadRuntimeItems, @@ -11,9 +24,16 @@ import { import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useWorktreeDeleteStore } from "@/renderer/state/worktreeDeleteStore"; import { readWorktreeDeletePref } from "@/renderer/views/MainView/parts/Sidebar/parts/DeleteWorktreeDialog"; +import { buildProjectDraftConfig } from "@/renderer/views/MainView/parts/AppContent/draftConfig"; import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers"; -import { closeThreads } from "@/renderer/utils/shellUtils"; -import { closePanelsForUnloadedThread } from "./panelActions"; +import { + closeThreads, + normalizeShellScript, + startShellWithToast, + writeScriptToShellThenExitOnSuccess, +} from "@/renderer/utils/shellUtils"; +import { generateTitleAsync } from "@/renderer/utils/titleGen"; +import { closeAllPanels, closePanelsForUnloadedThread } from "./panelActions"; import { getCurrentProjectId } from "./currentProject"; import { performWorktreeRemoval } from "./worktreeActions"; @@ -75,6 +95,120 @@ export function openNewThreadInWorktree(input: { }); } +export type DraftThreadStartInput = { + agentKind: AgentStatus["kind"]; + config: ThreadConfig; + prompt: string; + segments?: PromptSegment[]; + existingWorktreePath?: string; + worktreeBranch?: string; + worktreeBaseBranch?: string; + worktreeIsNewBranch?: boolean; + presentationMode?: ThreadPresentationMode; +}; + +export async function startThreadFromDraft( + project: Project, + input: DraftThreadStartInput, + options: { replacePaneId?: string; preserveActiveGroup?: boolean } = {}, +): Promise { + const { + agentKind, + config, + prompt, + segments, + existingWorktreePath, + worktreeBranch, + worktreeBaseBranch, + worktreeIsNewBranch, + presentationMode, + } = input; + const isHomeScope = isHomeProject(project); + const store = useAppStore.getState(); + + store.updateProjectDraftConfig( + project.id, + buildProjectDraftConfig({ + agentKind, + config, + worktreeMode: !isHomeScope && worktreeIsNewBranch === true, + }), + ); + + let worktreePath: string | undefined; + let newWorktreeSetupPath: string | undefined; + if (isHomeScope) { + worktreePath = undefined; + } else if (existingWorktreePath) { + worktreePath = existingWorktreePath; + } else if (worktreeBranch) { + try { + const result = await readBridge().gitAddWorktree({ + projectLocation: project.location, + branch: worktreeBranch, + createBranch: worktreeIsNewBranch ?? false, + startPoint: worktreeBaseBranch, + }); + worktreePath = result.path; + newWorktreeSetupPath = result.path; + } catch (err) { + console.error("[renderer] failed to create worktree:", err); + return null; + } + } + + const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); + const projectAgentStatuses = getProjectAgentStatuses( + project.location, + agentStatuses, + wslAgentStatuses, + ); + const titlePrompt = segments + ? segments + .filter((s) => s.kind !== "attachment") + .map((s) => (s.kind === "file" ? `@${s.path}` : s.content)) + .join("") + .trim() || prompt + : prompt; + const currentView = useAppStore.getState().view; + const activeGroup = + options.preserveActiveGroup !== false && + currentView.kind === "thread" && + currentView.activeGroupId + ? { + groupId: currentView.activeGroupId, + groupName: useAppStore + .getState() + .threads.find((t) => t.groupId === currentView.activeGroupId)?.groupName, + } + : undefined; + + const thread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind, + config, + prompt: titlePrompt, + ...(presentationMode ? { presentationMode } : {}), + ...(worktreePath ? { worktreePath, worktreeBranch } : {}), + ...(options.replacePaneId ? { replacePaneId: options.replacePaneId } : {}), + ...(activeGroup?.groupId ? { groupId: activeGroup.groupId } : {}), + ...(activeGroup?.groupName ? { groupName: activeGroup.groupName } : {}), + }); + useAppStore.getState().queueThreadLaunch(thread.id, prompt, segments); + generateTitleAsync(thread.id, project.location, projectAgentStatuses, titlePrompt); + if (worktreePath) { + void primeWorktreeGitState(project, worktreePath); + void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); + } + if (newWorktreeSetupPath) { + const setupScript = project.scripts?.setupScript; + if (setupScript) { + runWorktreeSetupScript(project, newWorktreeSetupPath, setupScript); + } + } + return thread; +} + export function openThread(threadId: string, options?: { focusComposer?: boolean }): void { const thread = useAppStore.getState().threads.find((item) => item.id === threadId); const requestId = ++openThreadRequestId; @@ -320,3 +454,79 @@ export function reopenPaneThreadsIfInactive(): void { reopenStoredThread(thread.id); } } + +async function primeWorktreeGitState(project: Project, worktreePath: string): Promise { + const cachedWorktreePaths = + useGitStore + .getState() + .worktrees[project.id]?.filter((worktree) => !worktree.isMain) + .map((worktree) => worktree.path) ?? []; + const threadWorktreePaths = useAppStore + .getState() + .threads.flatMap((thread) => + thread.projectId === project.id && thread.worktreePath ? [thread.worktreePath] : [], + ); + const worktreePaths = [ + ...new Set([...cachedWorktreePaths, ...threadWorktreePaths, worktreePath]), + ].sort(); + const watchWorktrees = readBridge() + .gitWatchWorktrees({ projectId: project.id, worktreePaths }) + .catch(() => undefined); + if (project.location.kind === "wsl") return; + await watchWorktrees; + void readBridge() + .getGitStatus({ projectLocation: buildWorktreeLocation(project.location, worktreePath) }) + .then((status) => useGitStore.getState().setWorktreeStatus(worktreePath, status)) + .catch(() => undefined); +} + +function runWorktreeSetupScript(project: Project, worktreePath: string, setupScript: string): void { + if (!normalizeShellScript(setupScript)) return; + + const wtLocation = buildWorktreeLocation(project.location, worktreePath); + const store = useDevTerminalStore.getState(); + const tab = store.addTab(project.id, "setup", worktreePath); + const autoShow = useSharedSettings.getState().autoShowTerminalPanel; + const panelAlreadyOpen = store.isOpen; + if (autoShow) { + store.openWorktreePanel(project.id, worktreePath); + } + store.setActiveTab(tab.id); + + if (!autoShow && !panelAlreadyOpen) { + startShellWithToast( + { + shellId: tab.id, + projectLocation: wtLocation, + worktreePath, + }, + "setup shell", + ); + } + + const detach = writeScriptToShellThenExitOnSuccess(tab.id, setupScript, wtLocation.kind, () => + removeWorktreeSetupTab(tab), + ); + const unsubscribeTabs = useDevTerminalStore.subscribe((state, prev) => { + if (state.tabs === prev.tabs) return; + if (state.tabs.some((t) => t.id === tab.id)) return; + detach(); + unsubscribeTabs(); + }); +} + +function removeWorktreeSetupTab(tab: DevTerminalTab): void { + const store = useDevTerminalStore.getState(); + const showingThisContext = + store.isOpen && + store.activeProjectId === tab.projectId && + (store.activeWorktreePath ?? undefined) === tab.worktreePath; + store.removeTab(tab.id); + if (!showingThisContext) return; + const remaining = useDevTerminalStore + .getState() + .tabs.filter((t) => t.projectId === tab.projectId && t.worktreePath === tab.worktreePath); + if (remaining.length > 0) return; + if (useSharedSettings.getState().terminalPosition !== "bottom") closeAllPanels(); + useDevTerminalStore.getState().closePanel(); +} diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index 9d0560fd..3d93e73a 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -8,6 +8,7 @@ import { openThread, unloadThread } from "@/renderer/actions/threadActions"; const { bridge } = vi.hoisted(() => ({ bridge: { + windowKind: "main", pickFolder: vi.fn<() => Promise>().mockResolvedValue(null), listWslDistros: vi.fn<() => Promise>().mockResolvedValue([]), getAgentStatuses: vi @@ -127,6 +128,12 @@ const { bridge } = vi.hoisted(() => ({ startUpdateDownload: vi.fn<() => Promise>().mockResolvedValue(undefined), installUpdate: vi.fn<() => Promise>().mockResolvedValue(undefined), onUpdateStatus: vi.fn<() => () => void>(() => () => undefined), + setQuickOverlayExpanded: vi.fn<() => Promise>().mockResolvedValue(undefined), + closeQuickOverlay: vi.fn<() => Promise>().mockResolvedValue(undefined), + notifyQuickOverlayThreadChanged: vi.fn<() => Promise>().mockResolvedValue(undefined), + openQuickOverlayThreadInMainWindow: vi.fn<() => Promise>().mockResolvedValue(undefined), + onExternalAppStoreChanged: vi.fn<() => () => void>(() => () => undefined), + onOpenThreadInMainWindow: vi.fn<() => () => void>(() => () => undefined), listAcpRegistry: vi.fn<() => Promise>().mockResolvedValue([]), onBrowserEvent: vi.fn<() => () => void>(() => () => undefined), browserGetState: vi @@ -139,6 +146,7 @@ vi.mock("./bridge", () => ({ readBridge: () => bridge, isWindows: () => false, isMac: () => false, + isQuickOverlay: () => bridge.windowKind === "quickOverlay", })); vi.mock("./components/ui/provider", () => ({ diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 052bd08b..0e427447 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { PixelLoader } from "./components/common"; import { msg } from "@/shared/messages"; import type { RuntimeEvent } from "@/shared/contracts"; -import { readBridge } from "./bridge"; +import { isQuickOverlay, readBridge } from "./bridge"; import { handleThreadStateNotification, shouldInspectThreadStateForNotification, @@ -16,10 +16,12 @@ import { useProviderUsageStore } from "./state/providerUsageStore"; import { useUpdateStore } from "./state/updateStore"; import { installRuntimeItemsPersister } from "./state/chatRuntimePersister"; import { clearRuntimeItemStoreSelectorCacheForThread } from "./components/thread/ChatPane/chatPaneSelectors"; +import { openThread } from "./actions/threadActions"; import { useAppHydration } from "@/renderer/hooks/useAppHydration"; import { AppProvider } from "./components/ui/provider"; import { MainView } from "@/renderer/views/MainView/MainView"; +import { QuickComposerOverlay } from "@/renderer/views/QuickComposerOverlay/QuickComposerOverlay"; import { CommandPalette } from "@/renderer/commands/CommandPalette"; import { captureAppStarted, @@ -230,9 +232,15 @@ if (import.meta.hot) { } export function App() { - const { initialLoading, storeHydrated, loadT0 } = useAppHydration(); + const isOverlay = isQuickOverlay(); + const { initialLoading, storeHydrated, loadT0 } = useAppHydration({ + runtimeOwner: !isOverlay, + }); useEffect(() => { + if (isOverlay) { + return; + } if (initialLoading) { threadStateNotificationsArmed = false; return; @@ -256,7 +264,24 @@ export function App() { threadStateNotificationsArmed = false; void flushProductAnalytics(); }; - }, [initialLoading]); + }, [initialLoading, isOverlay]); + + useEffect(() => { + if (isOverlay) return; + return readBridge().onExternalAppStoreChanged(() => { + void useAppStore.persist.rehydrate(); + }); + }, [isOverlay]); + + useEffect(() => { + if (isOverlay) return; + return readBridge().onOpenThreadInMainWindow(({ threadId }) => { + void (async () => { + await useAppStore.persist.rehydrate(); + openThread(threadId, { focusComposer: true }); + })(); + }); + }, [isOverlay]); if (initialLoading) { console.log( @@ -276,8 +301,14 @@ export function App() { return ( - - + {isOverlay ? ( + + ) : ( + <> + + + + )} ); } diff --git a/src/renderer/bridge.ts b/src/renderer/bridge.ts index 76fc80a7..22d1c887 100644 --- a/src/renderer/bridge.ts +++ b/src/renderer/bridge.ts @@ -15,3 +15,7 @@ export function isMac(): boolean { export function isDevApp(): boolean { return readBridge().isDev === true; } + +export function isQuickOverlay(): boolean { + return readBridge().windowKind === "quickOverlay"; +} diff --git a/src/renderer/components/thread/threadViewHandlers.ts b/src/renderer/components/thread/threadViewHandlers.ts new file mode 100644 index 00000000..f1d7b85b --- /dev/null +++ b/src/renderer/components/thread/threadViewHandlers.ts @@ -0,0 +1,142 @@ +import { startTransition, type ComponentProps } from "react"; +import type { ProjectLocation, Thread } from "@/shared/contracts"; +import { isHomeProjectId } from "@/shared/homeScope"; +import { buildPromptContentBlocks } from "@/shared/promptContent"; +import { readBridge } from "@/renderer/bridge"; +import { captureThreadInputSubmitted } from "@/renderer/analytics/posthog"; +import { useAppStore } from "@/renderer/state/appStore"; +import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions"; +import { ThreadView } from "@/renderer/components/thread/ThreadView"; + +type ThreadViewProps = ComponentProps; + +export interface ThreadViewHandlers { + onConfigChange: NonNullable; + onLaunchConsumed: NonNullable; + onLaunchFailed: NonNullable; + onResolveServerRequest: NonNullable; + onSubmitInput: NonNullable; +} + +/** + * Shared `ThreadView` host handlers used wherever a thread is driven (the main + * window panes and the quick-composer overlay). Returns fresh closures over the + * current `thread`, matching the per-render semantics of inline handlers. + */ +export function buildThreadViewHandlers( + thread: Thread, + projectLocation: ProjectLocation, +): ThreadViewHandlers { + const { + applyRuntimeEvent, + updateThreadConfig, + updateThreadRuntime, + consumeThreadLaunch, + touchThread, + } = useAppStore.getState(); + + return { + onConfigChange: (config) => { + updateThreadConfig(thread.id, config); + // If the thread was in an error state, clearing it now lets the user + // see the header status return to normal (non-red) as they've taken + // action to address the failure (e.g. by switching models). + if (thread.status === "error") { + updateThreadRuntime(thread.id, { + status: "idle", + attention: "none", + canResumeWithConfig: thread.canResumeWithConfig, + ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), + }); + } + }, + onLaunchConsumed: () => consumeThreadLaunch(thread.id), + onLaunchFailed: (message) => { + startTransition(() => { + applyRuntimeEvent(thread.id, { + type: "error", + threadId: thread.id, + message, + }); + updateThreadRuntime(thread.id, { + status: "error", + attention: "error", + ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), + canResumeWithConfig: thread.canResumeWithConfig || thread.sessionRef !== undefined, + }); + }); + }, + onResolveServerRequest: async ({ requestId, method, response }) => { + await readBridge().resolveThreadServerRequest({ + threadId: thread.id, + requestId, + method, + response, + }); + touchThread(thread.id); + }, + onSubmitInput: async (prompt, segments) => { + // Optimistic user_message for GUI threads: paint the typed prompt + // into the chat pane synchronously so it shows before the IPC + // round-trip + (for first prompts) ACP handshake completes. The + // supervisor reuses the same item id end-to-end so duplicates are + // dropped by the renderer's per-id dedupe in `applyRuntimeEvent`. + const presentation = thread.presentationMode ?? "terminal"; + let optimisticUserMessageItemId: string | undefined; + let markedWorking = false; + if (presentation === "gui" && prompt.length > 0) { + optimisticUserMessageItemId = `user-${crypto.randomUUID()}`; + applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: optimisticUserMessageItemId, + itemType: "user_message", + payload: { content: buildPromptContentBlocks(prompt, segments) }, + }); + applyRuntimeEvent(thread.id, { + type: "item.completed", + threadId: thread.id, + itemId: optimisticUserMessageItemId, + }); + updateThreadRuntime(thread.id, { + status: "working", + attention: "working", + canResumeWithConfig: thread.canResumeWithConfig, + ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), + }); + markedWorking = true; + if (!isHomeProjectId(thread.projectId)) { + await captureFileCheckpoint({ + threadId: thread.id, + checkpointItemId: optimisticUserMessageItemId, + projectLocation, + }); + } + } + try { + await readBridge().sendThreadInput({ + threadId: thread.id, + prompt, + ...(segments ? { segments } : {}), + config: thread.config, + ...(optimisticUserMessageItemId + ? { userMessageItemId: optimisticUserMessageItemId } + : {}), + }); + } catch (error) { + if (markedWorking) { + updateThreadRuntime(thread.id, { + status: thread.status, + attention: thread.attention, + canResumeWithConfig: thread.canResumeWithConfig, + forceCloseActiveTurn: true, + ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), + }); + } + throw error; + } + captureThreadInputSubmitted(thread, segments); + touchThread(thread.id); + }, + }; +} diff --git a/src/renderer/hooks/useAgentStatusHydration.ts b/src/renderer/hooks/useAgentStatusHydration.ts new file mode 100644 index 00000000..0d648be3 --- /dev/null +++ b/src/renderer/hooks/useAgentStatusHydration.ts @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import type { AgentStatus } from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; +import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; +import { parseWslProjectDistrosKey } from "@/renderer/state/projectKeys"; + +function findMissingWslDistro(distros: readonly string[], statuses: readonly AgentStatus[]) { + const cachedDistros = new Set( + statuses.flatMap((status) => (status.envDistro ? [status.envDistro] : [])), + ); + return distros.find((distro) => !cachedDistros.has(distro)); +} + +/** + * Triggers agent detection in the supervisor. When a cache is available the RPC + * resolves immediately with the previously-detected statuses so the first + * ThreadDraft render has real agents instead of the empty initial state. Fresh + * detection results still arrive via events (windows-agent-statuses, + * wsl-agent-statuses). Shared by the main window and the quick-composer overlay. + */ +export function useAgentStatusHydration(wslProjectDistrosKey: string, enabled = true): void { + useEffect(() => { + if (!enabled) { + return; + } + const wslDistros = parseWslProjectDistrosKey(wslProjectDistrosKey); + void readBridge() + .getAgentStatuses(wslDistros) + .then((response) => { + const missingWslDistro = findMissingWslDistro(wslDistros, response.wsl); + if (response.fromCache) { + useAgentStatusesStore.getState().hydrateFromCache({ + windows: response.windows, + wsl: response.wsl, + }); + if (missingWslDistro) { + useAgentStatusesStore + .getState() + .beginFirstLaunchDiscovery({ kind: "wsl", distro: missingWslDistro }); + } + return; + } + useAgentStatusesStore + .getState() + .beginFirstLaunchDiscovery( + missingWslDistro ? { kind: "wsl", distro: missingWslDistro } : undefined, + ); + }) + .catch(() => undefined); + }, [enabled, wslProjectDistrosKey]); +} diff --git a/src/renderer/hooks/useAppHydration.ts b/src/renderer/hooks/useAppHydration.ts index a495ff69..fc6ba406 100644 --- a/src/renderer/hooks/useAppHydration.ts +++ b/src/renderer/hooks/useAppHydration.ts @@ -19,7 +19,8 @@ function scheduleIdle(work: () => void): IdleCallbackHandle { return { cancel: () => clearTimeout(timeoutId) }; } -export function useAppHydration() { +export function useAppHydration(options: { runtimeOwner?: boolean } = {}) { + const runtimeOwner = options.runtimeOwner ?? true; const markThreadsInactiveOnLaunch = useAppStore((state) => state.markThreadsInactiveOnLaunch); const purgeStaleArchivedThreads = useAppStore((state) => state.purgeStaleArchivedThreads); const archiveOldDoneThreads = useAppStore((state) => state.archiveOldDoneThreads); @@ -59,6 +60,13 @@ export function useAppHydration() { `[renderer] +${Date.now() - loadT0}ms: store hydrated, view=${JSON.stringify(restoredView)}, ${useAppStore.getState().projects.length} projects, ${useAppStore.getState().threads.length} threads`, ); + if (!runtimeOwner) { + startTransition(() => { + setInitialLoading(false); + }); + return; + } + void (async () => { startTransition(() => { markThreadsInactiveOnLaunch(); @@ -134,11 +142,12 @@ export function useAppHydration() { purgeStaleArchivedThreads, archiveOldDoneThreads, reconcileRuntimeSnapshots, + runtimeOwner, storeHydrated, ]); useEffect(() => { - if (!storeHydrated || initialLoading) { + if (!runtimeOwner || !storeHydrated || initialLoading) { return; } @@ -160,7 +169,7 @@ export function useAppHydration() { return () => { cancelled = true; }; - }, [storeHydrated, initialLoading, updateThreadRuntime, view]); + }, [runtimeOwner, storeHydrated, initialLoading, updateThreadRuntime, view]); return { initialLoading, storeHydrated, loadT0 }; } diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 84315efe..713a2d18 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -36,6 +36,8 @@ initializeRendererSentry(); document.documentElement.dataset.platform = typeof window !== "undefined" && "lightcode" in window ? readBridge().platform : "unknown"; +document.documentElement.dataset.windowKind = + typeof window !== "undefined" && "lightcode" in window ? readBridge().windowKind : "main"; // Apply the cached appearance + theme before first paint so a non-default theme // doesn't flash the base palette on launch. diff --git a/src/renderer/state/dbStorage.test.ts b/src/renderer/state/dbStorage.test.ts index df586cfc..359be652 100644 --- a/src/renderer/state/dbStorage.test.ts +++ b/src/renderer/state/dbStorage.test.ts @@ -11,6 +11,7 @@ const bridge = vi.hoisted(() => ({ vi.mock("../bridge", () => ({ readBridge: () => bridge, + isQuickOverlay: () => false, })); describe("createDbStorage", () => { diff --git a/src/renderer/state/dbStorage.ts b/src/renderer/state/dbStorage.ts index 6d99e08e..1eba787c 100644 --- a/src/renderer/state/dbStorage.ts +++ b/src/renderer/state/dbStorage.ts @@ -1,5 +1,5 @@ import type { PersistStorage, StorageValue } from "zustand/middleware"; -import { readBridge } from "../bridge"; +import { isQuickOverlay, readBridge } from "../bridge"; import type { Project, Thread, AppView } from "@/shared/contracts"; /** @@ -13,6 +13,10 @@ function hasBridge(): boolean { return typeof window !== "undefined" && window.lightcode !== undefined; } +function isQuickOverlayWindow(): boolean { + return hasBridge() && isQuickOverlay(); +} + const APP_STORE_NAME = "lightcode-app-v2"; const lastStorageValues = new Map>(); const lastStorageJson = new Map(); @@ -34,6 +38,7 @@ const dbStorageBackend = { return; } if (name === APP_STORE_NAME) { + if (isQuickOverlayWindow()) return; return saveAppStore(value); } void readBridge().dbSetState(name, json); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 32ac0613..91d6bf20 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -895,10 +895,20 @@ body { overflow: hidden; } +html[data-window-kind="quickOverlay"] body { + background: transparent; +} + #root { width: 100%; } +html[data-window-kind="quickOverlay"], +html[data-window-kind="quickOverlay"] body, +html[data-window-kind="quickOverlay"] #root { + height: 100%; +} + .lightcode-shell { position: relative; box-sizing: border-box; @@ -966,6 +976,116 @@ html[data-platform="darwin"] .lightcode-content-over-drag-region--drag { app-region: no-drag; } +.quick-composer-root { + width: 100%; + height: 100%; + padding: 10px; + background: transparent; + opacity: 0; + transform: scale(0.96) translateY(-8px); + animation: quick-composer-enter 140ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards; +} + +.quick-composer-root--expanding .quick-composer-frame, +.quick-composer-root--thread .quick-composer-frame { + transform: scale(1); +} + +.quick-composer-root--closing { + opacity: 1; + transform: scale(1); + animation: quick-composer-exit 140ms cubic-bezier(0.4, 0, 1, 1) forwards; +} + +.quick-composer-frame { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in oklab, var(--border) 72%, transparent); + border-radius: 8px; + background: color-mix(in oklab, var(--overlay) 96%, transparent); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.32); + transform: scale(0.985); + transition: + transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 180ms ease, + box-shadow 180ms ease; +} + +.quick-composer-titlebar { + display: flex; + height: 42px; + flex-shrink: 0; + align-items: center; + gap: 6px; + padding: 8px 10px 6px 14px; + border-bottom: 1px solid color-mix(in oklab, var(--border) 55%, transparent); + -webkit-app-region: drag; + app-region: drag; +} + +.quick-composer-body { + min-height: 0; + flex: 1; +} + +.quick-composer-body > div { + min-height: 0; +} + +.quick-composer-icon-button { + display: inline-flex; + width: 28px; + height: 28px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + color: color-mix(in oklab, var(--muted) 82%, var(--foreground)); + transition: + background-color 120ms ease, + color 120ms ease; + -webkit-app-region: no-drag; + app-region: no-drag; +} + +.quick-composer-icon-button:hover { + background: color-mix(in oklab, var(--foreground) 8%, transparent); + color: var(--foreground); +} + +.quick-composer-text-button { + border-radius: 7px; + border: 1px solid color-mix(in oklab, var(--border) 75%, transparent); + padding: 6px 10px; + font-size: 0.8125rem; + color: var(--foreground); + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.quick-composer-text-button:hover { + border-color: color-mix(in oklab, var(--accent) 42%, var(--border)); + background: color-mix(in oklab, var(--accent) 10%, transparent); +} + +@keyframes quick-composer-enter { + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes quick-composer-exit { + to { + opacity: 0; + transform: scale(0.96) translateY(-8px); + } +} + .lightcode-git-diff-content { contain: layout paint; } diff --git a/src/renderer/views/MainView/MainView.tsx b/src/renderer/views/MainView/MainView.tsx index 9c9c90b5..21076334 100644 --- a/src/renderer/views/MainView/MainView.tsx +++ b/src/renderer/views/MainView/MainView.tsx @@ -1,15 +1,13 @@ import { startTransition, useEffect } from "react"; -import type { AgentStatus } from "@/shared/contracts"; import { buildPaneLayoutFromLegacy } from "@/shared/paneLayout"; -import { readBridge } from "@/renderer/bridge"; import { ensureHomeScopeProject } from "@/renderer/actions/projectActions"; -import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useAppStore } from "@/renderer/state/appStore"; -import { buildWslProjectDistrosKey, parseWslProjectDistrosKey } from "@/renderer/state/projectKeys"; +import { buildWslProjectDistrosKey } from "@/renderer/state/projectKeys"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { AppDndProvider } from "@/renderer/dnd"; +import { useAgentStatusHydration } from "@/renderer/hooks/useAgentStatusHydration"; import { useKeyboardShortcuts } from "@/renderer/hooks/useKeyboardShortcuts"; import { useWslDetection } from "@/renderer/hooks/useWslDetection"; import { useGitRefresh } from "@/renderer/hooks/useGitRefresh"; @@ -23,13 +21,6 @@ import { PullFromSourceDialog } from "@/renderer/views/MainView/parts/PullFromSo import { MainPageLayout, StalePanelCleanup } from "@/renderer/views/MainView/parts/MainPageLayout"; import { ThreadSearchOverlayHost } from "@/renderer/views/ThreadSearchOverlay/ThreadSearchOverlay"; -function findMissingWslDistro(distros: readonly string[], statuses: readonly AgentStatus[]) { - const cachedDistros = new Set( - statuses.flatMap((status) => (status.envDistro ? [status.envDistro] : [])), - ); - return distros.find((distro) => !cachedDistros.has(distro)); -} - export function MainView(props: { storeHydrated: boolean; loadT0: number }) { const { storeHydrated, loadT0 } = props; const view = useAppStore((state) => state.view); @@ -43,6 +34,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { useKeyboardShortcuts(); useGitRefresh(storeHydrated); useBrowserSync(); + useAgentStatusHydration(wslProjectDistrosKey, storeHydrated); const { handleSortEnd, handlePaneDrop, handleMainPanelDrop } = useDndHandlers(); @@ -54,44 +46,6 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { void ensureHomeScopeProject().catch(() => undefined); }, [storeHydrated, sharedSettingsHydrated, homeScopeEnabled]); - useEffect(() => { - if (!storeHydrated) { - return; - } - - // Triggers detection in the supervisor. When cache is available the RPC - // resolves immediately with the previously-detected statuses so the first - // ThreadDraft render has real agents instead of the empty initial state. - // Fresh detection results still arrive via events - // (windows-agent-statuses, wsl-agent-statuses). - const wslDistros = parseWslProjectDistrosKey(wslProjectDistrosKey); - void readBridge() - .getAgentStatuses(wslDistros) - .then((response) => { - const missingWslDistro = findMissingWslDistro(wslDistros, response.wsl); - if (response.fromCache) { - useAgentStatusesStore.getState().hydrateFromCache({ - windows: response.windows, - wsl: response.wsl, - }); - if (!missingWslDistro) { - return; - } - useAgentStatusesStore - .getState() - .beginFirstLaunchDiscovery({ kind: "wsl", distro: missingWslDistro }); - return; - } - - useAgentStatusesStore - .getState() - .beginFirstLaunchDiscovery( - missingWslDistro ? { kind: "wsl", distro: missingWslDistro } : undefined, - ); - }) - .catch(() => undefined); - }, [storeHydrated, wslProjectDistrosKey]); - console.log(`[renderer] +${Date.now() - loadT0}ms: rendering main UI`); return ( <> diff --git a/src/renderer/views/MainView/parts/AppContent/AppContent.tsx b/src/renderer/views/MainView/parts/AppContent/AppContent.tsx index 73f8ea24..5ebb5a0f 100644 --- a/src/renderer/views/MainView/parts/AppContent/AppContent.tsx +++ b/src/renderer/views/MainView/parts/AppContent/AppContent.tsx @@ -11,37 +11,25 @@ import type { ThreadPresentationMode, } from "@/shared/contracts"; import { getProjectAgentStatuses } from "@/shared/agentStatus"; -import { isHomeProject } from "@/shared/homeScope"; -import { buildWorktreeLocation } from "@/shared/worktree"; import { isDraftPaneId, parseDraftProjectId } from "@/shared/paneId"; import { buildPaneLayoutFromLegacy, findPaneAlign } from "@/shared/paneLayout"; import { readBridge } from "@/renderer/bridge"; +import { startThreadFromDraft } from "@/renderer/actions/threadActions"; import { isDetectingAgentsForLocation, useAgentStatusesStore, } from "@/renderer/state/agentStatusesStore"; import { useAppStore } from "@/renderer/state/appStore"; -import { refreshGitProject } from "@/renderer/state/gitRefresh"; -import { useGitStore } from "@/renderer/state/gitStore"; -import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useInitialProjectDraftConfig, useProjectIds, useProjectWithoutDraftConfig, } from "@/renderer/state/useThread"; -import { useDevTerminalStore, type DevTerminalTab } from "@/renderer/state/devTerminalStore"; -import { closeAllPanels } from "@/renderer/actions/panelActions"; import { SplitPaneContainer, type Rect } from "@/renderer/components/layout/SplitPaneContainer"; import { macosTrafficLightPadClass } from "@/renderer/components/layout/sidebarChrome"; import { ThreadDraftView } from "@/renderer/components/thread/ThreadDraftView"; -import { - normalizeShellScript, - startShellWithToast, - writeScriptToShellThenExitOnSuccess, -} from "@/renderer/utils/shellUtils"; import { generateTitleAsync } from "@/renderer/utils/titleGen"; import { HomeView } from "@/renderer/views/HomeView"; -import { buildProjectDraftConfig } from "./draftConfig"; import { ThreadPane } from "./parts/ThreadPane"; import { DraftPane } from "./parts/DraftPane"; @@ -53,7 +41,6 @@ export function AppContent() { const draftLastDraftConfig = useInitialProjectDraftConfig(draftProjectId); const createThread = useAppStore((state) => state.createThread); const queueThreadLaunch = useAppStore((state) => state.queueThreadLaunch); - const updateProjectDraftConfig = useAppStore((state) => state.updateProjectDraftConfig); const activeGroupName = useAppStore((s) => { const v = s.view; if (v.kind !== "thread" || !v.activeGroupId) return undefined; @@ -75,100 +62,11 @@ export function AppContent() { }, replacePaneIdParam?: string, ) { - const { - agentKind, - config, - prompt, - segments, - existingWorktreePath, - worktreeBranch, - worktreeBaseBranch, - worktreeIsNewBranch, - presentationMode, - } = input; - const isHomeScope = isHomeProject(project); - - updateProjectDraftConfig( - project.id, - buildProjectDraftConfig({ - agentKind, - config, - worktreeMode: !isHomeScope && worktreeIsNewBranch === true, - }), - ); - - let worktreePath: string | undefined; - let newWorktreeSetupPath: string | undefined; - if (isHomeScope) { - worktreePath = undefined; - } else if (existingWorktreePath) { - worktreePath = existingWorktreePath; - } else if (worktreeBranch) { - try { - const result = await readBridge().gitAddWorktree({ - projectLocation: project.location, - branch: worktreeBranch, - createBranch: worktreeIsNewBranch ?? false, - startPoint: worktreeBaseBranch, - }); - worktreePath = result.path; - newWorktreeSetupPath = result.path; - } catch (err) { - console.error("[renderer] failed to create worktree:", err); - return; - } - } - - const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); - const projectAgentStatuses = getProjectAgentStatuses( - project.location, - agentStatuses, - wslAgentStatuses, + await startThreadFromDraft( + project, + input, + replacePaneIdParam ? { replacePaneId: replacePaneIdParam } : {}, ); - const titlePrompt = segments - ? segments - .filter((s) => s.kind !== "attachment") - .map((s) => (s.kind === "file" ? `@${s.path}` : s.content)) - .join("") - .trim() || prompt - : prompt; - const currentView = useAppStore.getState().view; - const activeGroup = - currentView.kind === "thread" && currentView.activeGroupId - ? { - groupId: currentView.activeGroupId, - groupName: useAppStore - .getState() - .threads.find((t) => t.groupId === currentView.activeGroupId)?.groupName, - } - : undefined; - - const thread = createThread({ - projectId: project.id, - agentKind, - config, - prompt: titlePrompt, - ...(presentationMode ? { presentationMode } : {}), - ...(worktreePath ? { worktreePath, worktreeBranch } : {}), - ...(replacePaneIdParam ? { replacePaneId: replacePaneIdParam } : {}), - ...(activeGroup?.groupId ? { groupId: activeGroup.groupId } : {}), - ...(activeGroup?.groupName ? { groupName: activeGroup.groupName } : {}), - }); - queueThreadLaunch(thread.id, prompt, segments); - generateTitleAsync(thread.id, project.location, projectAgentStatuses, titlePrompt); - if (worktreePath) { - void primeWorktreeGitState(project, worktreePath); - // Full refresh so the new worktree enters the cache and any new branch - // from createBranch shows up in BranchSelectors and worktreeSourceInfo - // without waiting for the next background refresh. - void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); - } - if (newWorktreeSetupPath) { - const setupScript = project.scripts?.setupScript; - if (setupScript) { - runWorktreeSetupScript(project, newWorktreeSetupPath, setupScript); - } - } } async function handleContinueInProvider( @@ -377,103 +275,6 @@ export function AppContent() { ); } -async function primeWorktreeGitState(project: Project, worktreePath: string): Promise { - const cachedWorktreePaths = - useGitStore - .getState() - .worktrees[project.id]?.filter((worktree) => !worktree.isMain) - .map((worktree) => worktree.path) ?? []; - const threadWorktreePaths = useAppStore - .getState() - .threads.flatMap((thread) => - thread.projectId === project.id && thread.worktreePath ? [thread.worktreePath] : [], - ); - const worktreePaths = [ - ...new Set([...cachedWorktreePaths, ...threadWorktreePaths, worktreePath]), - ].sort(); - const watchWorktrees = readBridge() - .gitWatchWorktrees({ projectId: project.id, worktreePaths }) - .catch(() => undefined); - if (project.location.kind === "wsl") return; - await watchWorktrees; - void readBridge() - .getGitStatus({ projectLocation: buildWorktreeLocation(project.location, worktreePath) }) - .then((status) => useGitStore.getState().setWorktreeStatus(worktreePath, status)) - .catch(() => undefined); -} - -function runWorktreeSetupScript(project: Project, worktreePath: string, setupScript: string): void { - // Blank / comments-only scripts have nothing to run — skip the terminal - // entirely rather than leaving an idle "setup" shell behind. - if (!normalizeShellScript(setupScript)) return; - - const wtLocation = buildWorktreeLocation(project.location, worktreePath); - const store = useDevTerminalStore.getState(); - const tab = store.addTab(project.id, "setup", worktreePath); - const autoShow = useSharedSettings.getState().autoShowTerminalPanel; - const panelAlreadyOpen = store.isOpen; - if (autoShow) { - store.openWorktreePanel(project.id, worktreePath); - } - store.setActiveTab(tab.id); - - // When the terminal panel is (or becomes) visible, every tab mounts its own - // XTermSurface, which spawns the PTY itself at the measured viewport size. - // Spawning eagerly too would create a second PTY for the same shell id and - // race it, re-running the setup chain. Only spawn eagerly when the panel - // stays closed (no surface ever mounts), where this is the sole spawn. - if (!autoShow && !panelAlreadyOpen) { - startShellWithToast( - { - shellId: tab.id, - projectLocation: wtLocation, - worktreePath, - }, - "setup shell", - ); - } - - // Auto-close the setup terminal once every command succeeds (the script - // self-exits) so worktrees don't accumulate stale shells. A failed setup - // stops short of the exit and leaves the shell open for inspection. - const detach = writeScriptToShellThenExitOnSuccess(tab.id, setupScript, wtLocation.kind, () => - removeWorktreeSetupTab(tab), - ); - // If the tab is closed before setup finishes (manual close, worktree - // deletion), `closeThread` suppresses `thread-exited`, so detach the exit - // listener here instead of leaking it for the rest of the session. - const unsubscribeTabs = useDevTerminalStore.subscribe((state, prev) => { - if (state.tabs === prev.tabs) return; - if (state.tabs.some((t) => t.id === tab.id)) return; - detach(); - unsubscribeTabs(); - }); -} - -/** - * Removes a finished setup terminal tab. The PTY has already exited (success or - * a manual `exit`), so the supervisor session is gone — removing the tab is - * sufficient and `closeThread` would be a no-op. If the setup tab was the only - * one in the worktree context the panel is showing, close the panel too so the - * auto-opened panel doesn't linger on an empty "Open a terminal" state (mirrors - * manual close in DevTerminalPanel). - */ -function removeWorktreeSetupTab(tab: DevTerminalTab): void { - const store = useDevTerminalStore.getState(); - const showingThisContext = - store.isOpen && - store.activeProjectId === tab.projectId && - (store.activeWorktreePath ?? undefined) === tab.worktreePath; - store.removeTab(tab.id); - if (!showingThisContext) return; - const remaining = useDevTerminalStore - .getState() - .tabs.filter((t) => t.projectId === tab.projectId && t.worktreePath === tab.worktreePath); - if (remaining.length > 0) return; - if (useSharedSettings.getState().terminalPosition !== "bottom") closeAllPanels(); - useDevTerminalStore.getState().closePanel(); -} - /** * Draft view for the full-screen "draft" app view (no thread panes yet). * Subscribes to the agent statuses store so the composer re-renders when diff --git a/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx b/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx index 327293a4..e13da853 100644 --- a/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx +++ b/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx @@ -1,4 +1,4 @@ -import { startTransition, useRef } from "react"; +import { useRef } from "react"; import type { ExtractContextResult, PromptSegment, @@ -6,16 +6,11 @@ import type { ThreadConfig, ThreadPresentationMode, } from "@/shared/contracts"; -import { isHomeProjectId } from "@/shared/homeScope"; import { buildWorktreeLocation } from "@/shared/worktree"; -import { buildPromptContentBlocks } from "@/shared/promptContent"; -import { readBridge } from "@/renderer/bridge"; -import { captureThreadInputSubmitted } from "@/renderer/analytics/posthog"; import { toggleMarkThreadDone } from "@/renderer/actions/threadActions"; -import { useAppStore } from "@/renderer/state/appStore"; -import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions"; import { useProject, useThread } from "@/renderer/state/useThread"; import { ThreadView } from "@/renderer/components/thread/ThreadView"; +import { buildThreadViewHandlers } from "@/renderer/components/thread/threadViewHandlers"; import { useDraggable, useDroppable } from "@dnd-kit/react"; import { useIsDraggingPane, usePaneDropIndicatorState, type DragSourceData } from "@/renderer/dnd"; import { @@ -49,13 +44,6 @@ export function ThreadPane(props: { const { prompt: pendingLaunchPrompt, segments: pendingLaunchSegments } = useThreadPendingLaunch( props.threadId, ); - const { - applyRuntimeEvent, - updateThreadConfig, - updateThreadRuntime, - consumeThreadLaunch, - touchThread, - } = useAppStore.getState(); const paneElementRef = useRef(null); const { handleRef } = useDraggable({ @@ -80,6 +68,7 @@ export function ThreadPane(props: { const projectLocation = thread.worktreePath ? buildWorktreeLocation(project.location, thread.worktreePath) : project.location; + const handlers = buildThreadViewHandlers(thread, projectLocation); return ( { toggleMarkThreadDone(props.threadId); }} - onConfigChange={(config) => { - updateThreadConfig(thread.id, config); - // If the thread was in an error state, clearing it now lets the user - // see the header status return to normal (non-red) as they've taken - // action to address the failure (e.g. by switching models). - if (thread.status === "error") { - updateThreadRuntime(thread.id, { - status: "idle", - attention: "none", - canResumeWithConfig: thread.canResumeWithConfig, - ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), - }); - } - }} + onConfigChange={handlers.onConfigChange} projectLocation={projectLocation} - onLaunchConsumed={() => consumeThreadLaunch(thread.id)} - onLaunchFailed={(message) => { - startTransition(() => { - applyRuntimeEvent(thread.id, { - type: "error", - threadId: thread.id, - message, - }); - updateThreadRuntime(thread.id, { - status: "error", - attention: "error", - ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), - canResumeWithConfig: thread.canResumeWithConfig || thread.sessionRef !== undefined, - }); - }); - }} - onResolveServerRequest={async ({ requestId, method, response }) => { - await readBridge().resolveThreadServerRequest({ - threadId: thread.id, - requestId, - method, - response, - }); - touchThread(thread.id); - }} + onLaunchConsumed={handlers.onLaunchConsumed} + onLaunchFailed={handlers.onLaunchFailed} + onResolveServerRequest={handlers.onResolveServerRequest} {...(pendingLaunchPrompt !== undefined ? { pendingLaunchPrompt } : {})} {...(pendingLaunchSegments ? { pendingLaunchSegments } : {})} - onSubmitInput={async (prompt, segments) => { - // Optimistic user_message for GUI threads: paint the typed prompt - // into the chat pane synchronously so it shows before the IPC - // round-trip + (for first prompts) ACP handshake completes. The - // supervisor reuses the same item id end-to-end so duplicates are - // dropped by the renderer's per-id dedupe in `applyRuntimeEvent`. - const presentation = thread.presentationMode ?? "terminal"; - let optimisticUserMessageItemId: string | undefined; - let markedWorking = false; - if (presentation === "gui" && prompt.length > 0) { - optimisticUserMessageItemId = `user-${crypto.randomUUID()}`; - useAppStore.getState().applyRuntimeEvent(thread.id, { - type: "item.started", - threadId: thread.id, - itemId: optimisticUserMessageItemId, - itemType: "user_message", - payload: { content: buildPromptContentBlocks(prompt, segments) }, - }); - useAppStore.getState().applyRuntimeEvent(thread.id, { - type: "item.completed", - threadId: thread.id, - itemId: optimisticUserMessageItemId, - }); - updateThreadRuntime(thread.id, { - status: "working", - attention: "working", - canResumeWithConfig: thread.canResumeWithConfig, - ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), - }); - markedWorking = true; - if (!isHomeProjectId(thread.projectId)) { - await captureFileCheckpoint({ - threadId: thread.id, - checkpointItemId: optimisticUserMessageItemId, - projectLocation, - }); - } - } - try { - await readBridge().sendThreadInput({ - threadId: thread.id, - prompt, - ...(segments ? { segments } : {}), - config: thread.config, - ...(optimisticUserMessageItemId - ? { userMessageItemId: optimisticUserMessageItemId } - : {}), - }); - } catch (error) { - if (markedWorking) { - updateThreadRuntime(thread.id, { - status: thread.status, - attention: thread.attention, - canResumeWithConfig: thread.canResumeWithConfig, - forceCloseActiveTurn: true, - ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), - }); - } - throw error; - } - captureThreadInputSubmitted(thread, segments); - touchThread(thread.id); - }} + onSubmitInput={handlers.onSubmitInput} installedAgents={installedAgents} onContinueInProvider={ props.onContinueInProvider diff --git a/src/renderer/views/QuickComposerOverlay/QuickComposerOverlay.tsx b/src/renderer/views/QuickComposerOverlay/QuickComposerOverlay.tsx new file mode 100644 index 00000000..1eceae9a --- /dev/null +++ b/src/renderer/views/QuickComposerOverlay/QuickComposerOverlay.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { Maximize2, X } from "lucide-react"; +import type { Project } from "@/shared/contracts"; +import { getProjectAgentStatuses } from "@/shared/agentStatus"; +import { HOME_PROJECT_ID } from "@/shared/homeScope"; +import { isDraftPaneId, parseDraftProjectId } from "@/shared/paneId"; +import { buildWorktreeLocation } from "@/shared/worktree"; +import { readBridge } from "@/renderer/bridge"; +import { ensureHomeScopeProject } from "@/renderer/actions/projectActions"; +import { startThreadFromDraft, type DraftThreadStartInput } from "@/renderer/actions/threadActions"; +import { PixelLoader } from "@/renderer/components/common"; +import { ThreadDraftView } from "@/renderer/components/thread/ThreadDraftView"; +import { ThreadView } from "@/renderer/components/thread/ThreadView"; +import { buildThreadViewHandlers } from "@/renderer/components/thread/threadViewHandlers"; +import { + isDetectingAgentsForLocation, + useAgentStatusesStore, +} from "@/renderer/state/agentStatusesStore"; +import { useAppStore } from "@/renderer/state/appStore"; +import { buildWslProjectDistrosKey } from "@/renderer/state/projectKeys"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { useProject, useThread } from "@/renderer/state/useThread"; +import { useAgentStatusHydration } from "@/renderer/hooks/useAgentStatusHydration"; +import { useProjectAgentStatuses, useThreadPendingLaunch } from "@/renderer/hooks/uiSelectors"; + +type OverlayPhase = "draft" | "expanding" | "thread" | "closing"; + +export function QuickComposerOverlay() { + const [threadId, setThreadId] = useState(null); + const [phase, setPhase] = useState("draft"); + const view = useAppStore((s) => s.view); + const projects = useAppStore((s) => s.projects); + const threads = useAppStore((s) => s.threads); + const wslProjectDistrosKey = useAppStore((s) => buildWslProjectDistrosKey(s.projects)); + const homeScopeEnabled = useSharedSettings((s) => s.homeScopeEnabled); + const sharedSettingsHydrated = useSharedSettings((s) => s.sharedSettingsHydrated); + const project = resolveDefaultProject({ projects, threads, view, homeScopeEnabled }); + const projectAgentStatuses = useAgentStatusesStore((s) => + project ? getProjectAgentStatuses(project.location, s.agentStatuses, s.wslAgentStatuses) : [], + ); + const isDetectingAgents = useAgentStatusesStore((s) => + project ? isDetectingAgentsForLocation(s, project.location) : false, + ); + + useEffect(() => { + if (!sharedSettingsHydrated || !homeScopeEnabled) return; + void ensureHomeScopeProject() + .then((homeProject) => readBridge().dbUpsertProject(homeProject)) + .catch(() => undefined); + }, [homeScopeEnabled, sharedSettingsHydrated]); + + useAgentStatusHydration(wslProjectDistrosKey); + + async function handleStart(input: DraftThreadStartInput) { + if (!project) return; + setPhase("expanding"); + void readBridge().setQuickOverlayExpanded(true); + if (project.id === HOME_PROJECT_ID) { + await readBridge().dbUpsertProject(project); + } + const thread = await startThreadFromDraft(project, input, { preserveActiveGroup: false }); + if (!thread) { + setPhase("draft"); + void readBridge().setQuickOverlayExpanded(false); + return; + } + setThreadId(thread.id); + setPhase("thread"); + } + + function closeOverlay() { + setPhase("closing"); + void readBridge().setQuickOverlayExpanded(false); + setTimeout(() => { + void readBridge().closeQuickOverlay(); + }, 140); + } + + function openInMainWindow() { + if (!threadId) return; + setPhase("closing"); + void readBridge().openQuickOverlayThreadInMainWindow(threadId); + } + + return ( +
+
+
+
+

+ {threadId ? "Quick chat" : "New quick chat"} +

+

+ {project?.name ?? "Lightcode"} +

+
+ {threadId ? ( + + ) : null} + +
+
+ {!project ? ( + + ) : threadId ? ( + + ) : ( + void handleStart(input)} + /> + )} +
+
+
+ ); +} + +function QuickComposerEmptyState() { + return ( +
+

Add a project in Lightcode to start a quick chat.

+ +
+ ); +} + +function QuickOverlayThread(props: { threadId: string }) { + const thread = useThread(props.threadId); + const project = useProject(thread?.projectId); + const projectAgentStatuses = useProjectAgentStatuses(project?.location); + const agentStatus = projectAgentStatuses.find((status) => status.kind === thread?.agentKind); + const { prompt: pendingLaunchPrompt, segments: pendingLaunchSegments } = useThreadPendingLaunch( + props.threadId, + ); + + useEffect(() => { + if (!thread) return; + void readBridge() + .dbUpsertThread(thread) + .then(() => readBridge().notifyQuickOverlayThreadChanged(thread.id)) + .catch(() => undefined); + }, [thread]); + + if (!thread || !project) { + return ( +
+ +
+ ); + } + + const projectLocation = thread.worktreePath + ? buildWorktreeLocation(project.location, thread.worktreePath) + : project.location; + const handlers = buildThreadViewHandlers(thread, projectLocation); + + return ( + + ); +} + +function resolveDefaultProject(input: { + projects: Project[]; + threads: Array<{ id: string; projectId: string; archived?: boolean }>; + view: ReturnType["view"]; + homeScopeEnabled: boolean; +}): Project | undefined { + const byId = new Map(input.projects.map((project) => [project.id, project])); + const isUsableProject = (project: Project | undefined) => + project !== undefined && + (project.id === HOME_PROJECT_ID ? input.homeScopeEnabled : !project.disabled); + const fromView = resolveProjectIdFromView(input.view, input.threads); + const viewedProject = byId.get(fromView ?? ""); + if (isUsableProject(viewedProject)) return viewedProject; + + const latestThread = input.threads.find((thread) => !thread.archived); + const latestThreadProject = byId.get(latestThread?.projectId ?? ""); + if (isUsableProject(latestThreadProject)) return latestThreadProject; + + const homeProject = byId.get(HOME_PROJECT_ID); + if (input.homeScopeEnabled && homeProject) return homeProject; + + return input.projects.find((project) => !project.disabled && project.id !== HOME_PROJECT_ID); +} + +function resolveProjectIdFromView( + view: ReturnType["view"], + threads: Array<{ id: string; projectId: string }>, +): string | undefined { + if (view.kind === "draft") return view.projectId; + if (view.kind !== "thread") return undefined; + const firstPaneId = view.panes[0]; + if (!firstPaneId) return undefined; + if (isDraftPaneId(firstPaneId)) return parseDraftProjectId(firstPaneId) ?? undefined; + return threads.find((thread) => thread.id === firstPaneId)?.projectId; +} diff --git a/src/shared/ipc/bridge.ts b/src/shared/ipc/bridge.ts index c25624aa..3fc69202 100644 --- a/src/shared/ipc/bridge.ts +++ b/src/shared/ipc/bridge.ts @@ -10,6 +10,8 @@ import { } from "./procedureMap"; import type { BrowserEvent, SupervisorEvent, UpdateStatus } from "./events"; +export type LightcodeWindowKind = "main" | "quickOverlay"; + type ProcedureArgs = (typeof ipcProcedureMap)[Name]["__types"]["args"]; @@ -18,6 +20,7 @@ export type LightcodeInvokeBridge = { }; export type LightcodeBridge = LightcodeInvokeBridge & { + windowKind: LightcodeWindowKind; platform: NodeJS.Platform; appVersion: string; arch: string; @@ -35,6 +38,12 @@ export type LightcodeBridge = LightcodeInvokeBridge & { onSupervisorEvent(listener: (event: SupervisorEvent) => void): () => void; onUpdateStatus(listener: (status: UpdateStatus) => void): () => void; onBrowserEvent(listener: (event: BrowserEvent) => void): () => void; + setQuickOverlayExpanded(expanded: boolean): Promise; + closeQuickOverlay(): Promise; + notifyQuickOverlayThreadChanged(threadId: string): Promise; + openQuickOverlayThreadInMainWindow(threadId: string): Promise; + onExternalAppStoreChanged(listener: (event: { threadId?: string }) => void): () => void; + onOpenThreadInMainWindow(listener: (event: { threadId: string }) => void): () => void; }; export function createInvokeBridge( @@ -86,4 +95,13 @@ export const IPC_EVENT_CHANNELS = { supervisorEvent: createChannel("supervisorEvent"), updateStatus: createChannel("updateStatus"), browserEvent: createChannel("browserEvent"), + externalAppStoreChanged: createChannel("externalAppStoreChanged"), + openThreadInMainWindow: createChannel("openThreadInMainWindow"), +} as const; + +export const IPC_WINDOW_CHANNELS = { + quickOverlaySetExpanded: createChannel("quickOverlaySetExpanded"), + quickOverlayClose: createChannel("quickOverlayClose"), + quickOverlayThreadChanged: createChannel("quickOverlayThreadChanged"), + quickOverlayOpenThreadInMainWindow: createChannel("quickOverlayOpenThreadInMainWindow"), } as const; diff --git a/src/shared/ipc/index.ts b/src/shared/ipc/index.ts index 9ed44cd6..a392fb51 100644 --- a/src/shared/ipc/index.ts +++ b/src/shared/ipc/index.ts @@ -15,9 +15,11 @@ export { defineMainLocalIpcHandlers, defineSupervisorIpcHandlers, IPC_EVENT_CHANNELS, + IPC_WINDOW_CHANNELS, parseIpcProcedureArgs, type LightcodeBridge, type LightcodeInvokeBridge, + type LightcodeWindowKind, type MainLocalIpcHandlerMap, type SupervisorIpcHandlerMap, } from "./bridge"; From 290c4d17d9feae3e10610a3e3073d7f381dd5182 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 6 Jun 2026 08:32:22 -0700 Subject: [PATCH 2/2] refactor(main): singleton quick composer and shared main window factory - Track one quick composer and focus it instead of opening duplicates - Extract createMainAppWindow for startup and macOS activate paths - Simplify supervisor broadcast, activate focus, and quit cleanup --- src/main/main.ts | 125 +++++++++++++++++++---------------------------- 1 file changed, 50 insertions(+), 75 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 85bf8079..63d3a0e8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -77,7 +77,7 @@ const WINDOW_CHROME_HEIGHT = 32; const QUICK_COMPOSER_SHORTCUT = "CommandOrControl+L"; let mainWindow: BrowserWindow | null = null; -const quickComposerWindows = new Set(); +let quickComposerWindow: BrowserWindow | null = null; let lightcodePaths: LightcodePaths | null = null; let windowsJobObjectManager: WindowsJobObjectManager | null = null; let browserPanelManager: BrowserPanelManager | null = null; @@ -137,19 +137,22 @@ function focusMainWindow(): void { function quickComposerWindowFor(event: Electron.IpcMainInvokeEvent): BrowserWindow | null { const window = BrowserWindow.fromWebContents(event.sender); - return window && quickComposerWindows.has(window) && !window.isDestroyed() ? window : null; + return window && window === quickComposerWindow && !window.isDestroyed() ? window : null; } function sendSupervisorEventToRenderers(event: SupervisorEvent): void { mainWindow?.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); - for (const window of quickComposerWindows) { - if (!window.isDestroyed()) { - window.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); - } + if (quickComposerWindow && !quickComposerWindow.isDestroyed()) { + quickComposerWindow.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); } } function openQuickComposerWindow(): void { + if (quickComposerWindow && !quickComposerWindow.isDestroyed()) { + showAndFocusWindow(quickComposerWindow); + return; + } + const window = createQuickComposerWindow({ title: getAppName(channel, isDev), isDev, @@ -164,7 +167,9 @@ function openQuickComposerWindow(): void { sentryEnabled, ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), onClosed: () => { - quickComposerWindows.delete(window); + if (quickComposerWindow === window) { + quickComposerWindow = null; + } }, onRendererProcessGone: (details) => { captureMainException(new Error(`Quick composer renderer process gone: ${details.reason}`), { @@ -173,7 +178,38 @@ function openQuickComposerWindow(): void { }); }, }); - quickComposerWindows.add(window); + quickComposerWindow = window; +} + +function createMainAppWindow(): BrowserWindow { + const window = createMainWindow({ + title: getAppName(channel, isDev), + isDev, + channel, + preloadPath: join(__dirname, "preload.cjs"), + rendererHtmlPath: join(__dirname, "../renderer/index.html"), + appVersion: app.getVersion(), + posthogEnableDev, + posthogEnabled, + posthogHost, + posthogKey, + sentryEnabled, + windowChromeHeight: WINDOW_CHROME_HEIGHT, + appearance: resolveAppAppearance(), + ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), + onClosed: () => { + mainWindow = null; + }, + onClose: handleMainWindowClose, + onRendererProcessGone: (details) => { + captureMainException(new Error(`Renderer process gone: ${details.reason}`), { + "lightcode.feature_area": "renderer", + "lightcode.process": "renderer", + }); + }, + }); + browserPanelManager?.bindHost(window); + return window; } const workingThreads = new Set(); @@ -349,34 +385,7 @@ if (!hasSingleInstanceLock) { }, ); - mainWindow = createMainWindow({ - title: getAppName(channel, isDev), - isDev, - channel, - preloadPath: join(__dirname, "preload.cjs"), - rendererHtmlPath: join(__dirname, "../renderer/index.html"), - appVersion: app.getVersion(), - posthogEnableDev, - posthogEnabled, - posthogHost, - posthogKey, - sentryEnabled, - windowChromeHeight: WINDOW_CHROME_HEIGHT, - appearance: resolveAppAppearance(), - ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), - onClosed: () => { - mainWindow = null; - }, - onClose: handleMainWindowClose, - onRendererProcessGone: (details) => { - captureMainException(new Error(`Renderer process gone: ${details.reason}`), { - "lightcode.feature_area": "renderer", - "lightcode.process": "renderer", - }); - }, - }); - - browserPanelManager.bindHost(mainWindow); + mainWindow = createMainAppWindow(); tray = createTray({ window: mainWindow, @@ -434,42 +443,10 @@ if (!hasSingleInstanceLock) { app.on("activate", () => { if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - mainWindow.focus(); + showAndFocusWindow(mainWindow); return; } - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createMainWindow({ - title: getAppName(channel, isDev), - isDev, - channel, - preloadPath: join(__dirname, "preload.cjs"), - rendererHtmlPath: join(__dirname, "../renderer/index.html"), - appVersion: app.getVersion(), - posthogEnableDev, - posthogEnabled, - posthogHost, - posthogKey, - sentryEnabled, - windowChromeHeight: WINDOW_CHROME_HEIGHT, - appearance: resolveAppAppearance(), - ...(process.env.VITE_DEV_SERVER_URL - ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } - : {}), - onClosed: () => { - mainWindow = null; - }, - onClose: handleMainWindowClose, - onRendererProcessGone: (details) => { - captureMainException(new Error(`Renderer process gone: ${details.reason}`), { - "lightcode.feature_area": "renderer", - "lightcode.process": "renderer", - }); - }, - }); - } + mainWindow = createMainAppWindow(); }); app.on("before-quit", () => { @@ -485,12 +462,10 @@ if (!hasSingleInstanceLock) { sleepInhibitor.dispose(); tray?.destroy(); tray = null; - for (const window of quickComposerWindows) { - if (!window.isDestroyed()) { - window.close(); - } + if (quickComposerWindow && !quickComposerWindow.isDestroyed()) { + quickComposerWindow.close(); } - quickComposerWindows.clear(); + quickComposerWindow = null; }); }); }