diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b5b0d0461..e0edd5994 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -57,6 +57,7 @@ import { OAuthService } from "../services/oauth/service"; import { PosthogPluginService } from "../services/posthog-plugin/service"; import { ProcessTrackingService } from "../services/process-tracking/service"; import { ProvisioningService } from "../services/provisioning/service"; +import { QuickEntryService } from "../services/quick-entry/service"; import { settingsStore } from "../services/settingsStore"; import { ShellService } from "../services/shell/service"; import { SlackIntegrationService } from "../services/slack-integration/service"; @@ -148,5 +149,6 @@ container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); +container.bind(MAIN_TOKENS.QuickEntryService).to(QuickEntryService); container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 92d9a1287..4caa0f084 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -81,4 +81,5 @@ export const MAIN_TOKENS = Object.freeze({ ProvisioningService: Symbol.for("Main.ProvisioningService"), WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), + QuickEntryService: Symbol.for("Main.QuickEntryService"), }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index b8a125493..874556a1d 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import os from "node:os"; -import { app, BrowserWindow, dialog } from "electron"; +import { app, BrowserWindow, dialog, globalShortcut } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -24,6 +24,7 @@ import { trackAppEvent, } from "./services/posthog-analytics"; import type { PosthogPluginService } from "./services/posthog-plugin/service"; +import type { QuickEntryService } from "./services/quick-entry/service"; import type { SlackIntegrationService } from "./services/slack-integration/service"; import type { SuspensionService } from "./services/suspension/service"; import type { TaskLinkService } from "./services/task-link/service"; @@ -230,12 +231,42 @@ app.whenReady().then(async () => { createWindow(); await initializeServices(); initializeDeepLinks(); + initializeQuickEntry(); }); +function initializeQuickEntry(): void { + try { + const service = container.get( + MAIN_TOKENS.QuickEntryService, + ); + service.createWindow(); + + const accelerator = "Alt+Space"; + const ok = globalShortcut.register(accelerator, () => { + try { + service.toggle(); + } catch (err) { + log.error("Quick entry toggle failed", err); + } + }); + if (!ok) { + log.warn(`Failed to register global shortcut: ${accelerator}`); + } else { + log.info(`Registered quick-entry global shortcut: ${accelerator}`); + } + } catch (err) { + log.error("Failed to initialize quick entry", err); + } +} + app.on("window-all-closed", () => { app.quit(); }); +app.on("will-quit", () => { + globalShortcut.unregisterAll(); +}); + app.on("before-quit", async (event) => { let lifecycleService: AppLifecycleService; try { diff --git a/apps/code/src/main/services/quick-entry/schemas.ts b/apps/code/src/main/services/quick-entry/schemas.ts new file mode 100644 index 000000000..3e7c871ef --- /dev/null +++ b/apps/code/src/main/services/quick-entry/schemas.ts @@ -0,0 +1,29 @@ +export const QuickEntryServiceEvent = { + FocusInput: "focus-input", + Hide: "hide", + CreateTaskRequested: "create-task-requested", +} as const; + +export interface CreateTaskRequest { + content: string; + repoPath: string; + workspaceMode: "local" | "worktree"; + branch: string | null; + adapter: "claude" | "codex"; + model: string | null; + reasoningLevel: string | null; + executionMode: string | null; +} + +export interface QuickEntryServiceEvents { + [QuickEntryServiceEvent.FocusInput]: true; + [QuickEntryServiceEvent.Hide]: true; + [QuickEntryServiceEvent.CreateTaskRequested]: CreateTaskRequest; +} + +export interface RecentRepoEntry { + id: string; + path: string; + name: string; + remoteUrl: string | null; +} diff --git a/apps/code/src/main/services/quick-entry/service.ts b/apps/code/src/main/services/quick-entry/service.ts new file mode 100644 index 000000000..a1056f464 --- /dev/null +++ b/apps/code/src/main/services/quick-entry/service.ts @@ -0,0 +1,121 @@ +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + createQuickEntryWindow, + destroyQuickEntryWindow, + hideQuickEntryWindow, + isQuickEntryWindowFocused, + isQuickEntryWindowVisible, + showAndFocusMainWindow, + showQuickEntryWindow, +} from "../../window"; +import type { FoldersService } from "../folders/service"; +import { + type CreateTaskRequest, + QuickEntryServiceEvent, + type QuickEntryServiceEvents, + type RecentRepoEntry, +} from "./schemas"; + +const log = logger.scope("quick-entry"); + +const BLUR_HIDE_GRACE_MS = 120; +const SHOW_GRACE_MS = 200; + +@injectable() +export class QuickEntryService extends TypedEventEmitter { + private suppressBlurHide = false; + + constructor( + @inject(MAIN_TOKENS.FoldersService) + private readonly foldersService: FoldersService, + ) { + super(); + } + + // Idempotent: window.ts guards against double-creation, and if the window + // was destroyed (e.g. renderer crash) this recreates it. + private ensureWindow(): void { + createQuickEntryWindow({ + onBlur: () => this.handleBlur(), + }); + } + + createWindow(): void { + this.ensureWindow(); + } + + private handleBlur(): void { + if (this.suppressBlurHide) return; + // Child popups (dropdowns) briefly steal focus — grace period before hiding. + setTimeout(() => { + if (!isQuickEntryWindowVisible()) return; + if (isQuickEntryWindowFocused()) return; + this.hide(); + }, BLUR_HIDE_GRACE_MS); + } + + isVisible(): boolean { + return isQuickEntryWindowVisible(); + } + + toggle(): void { + if (this.isVisible()) { + this.hide(); + } else { + this.show(); + } + } + + show(): void { + // Lazily recreate the window if it was destroyed (renderer crash, OOM). + this.ensureWindow(); + this.suppressBlurHide = true; + const ok = showQuickEntryWindow(); + if (!ok) { + this.suppressBlurHide = false; + return; + } + this.emit(QuickEntryServiceEvent.FocusInput, true); + setTimeout(() => { + this.suppressBlurHide = false; + }, SHOW_GRACE_MS); + } + + hide(): void { + if (!isQuickEntryWindowVisible()) return; + hideQuickEntryWindow(); + this.emit(QuickEntryServiceEvent.Hide, true); + } + + requestCreateTask(request: CreateTaskRequest): void { + this.hide(); + showAndFocusMainWindow(); + this.emit(QuickEntryServiceEvent.CreateTaskRequested, request); + } + + async getRecentRepos(limit = 8): Promise { + const folders = await this.foldersService.getFolders(); + return folders + .filter((f) => f.exists) + .sort((a, b) => { + const ta = new Date(a.lastAccessed).getTime(); + const tb = new Date(b.lastAccessed).getTime(); + return tb - ta; + }) + .slice(0, limit) + .map((f) => ({ + id: f.id, + path: f.path, + name: f.name, + remoteUrl: f.remoteUrl, + })); + } + + dispose(): void { + destroyQuickEntryWindow(); + log.info("Quick entry service disposed"); + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 81fd00d4e..eda0355ef 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -28,6 +28,7 @@ import { oauthRouter } from "./routers/oauth"; import { osRouter } from "./routers/os"; import { processTrackingRouter } from "./routers/process-tracking"; import { provisioningRouter } from "./routers/provisioning"; +import { quickEntryRouter } from "./routers/quick-entry"; import { secureStoreRouter } from "./routers/secure-store"; import { shellRouter } from "./routers/shell"; import { skillsRouter } from "./routers/skills"; @@ -70,6 +71,7 @@ export const trpcRouter = router({ os: osRouter, processTracking: processTrackingRouter, provisioning: provisioningRouter, + quickEntry: quickEntryRouter, sleep: sleepRouter, suspension: suspensionRouter, secureStore: secureStoreRouter, diff --git a/apps/code/src/main/trpc/routers/quick-entry.ts b/apps/code/src/main/trpc/routers/quick-entry.ts new file mode 100644 index 000000000..d1e77a39e --- /dev/null +++ b/apps/code/src/main/trpc/routers/quick-entry.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + QuickEntryServiceEvent, + type QuickEntryServiceEvents, +} from "../../services/quick-entry/schemas"; +import type { QuickEntryService } from "../../services/quick-entry/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.QuickEntryService); + +function subscribeToQuickEntryEvent( + event: K, +) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +const createTaskRequestInput = z.object({ + content: z.string(), + repoPath: z.string(), + workspaceMode: z.enum(["local", "worktree"]), + branch: z.string().nullable(), + adapter: z.enum(["claude", "codex"]), + model: z.string().nullable(), + reasoningLevel: z.string().nullable(), + executionMode: z.string().nullable(), +}); + +export const quickEntryRouter = router({ + toggle: publicProcedure.mutation(() => { + getService().toggle(); + }), + + show: publicProcedure.mutation(() => { + getService().show(); + }), + + hide: publicProcedure.mutation(() => { + getService().hide(); + }), + + requestCreateTask: publicProcedure + .input(createTaskRequestInput) + .mutation(({ input }) => { + getService().requestCreateTask(input); + }), + + getRecentRepos: publicProcedure + .input( + z.object({ limit: z.number().int().positive().optional() }).optional(), + ) + .query(({ input }) => { + return getService().getRecentRepos(input?.limit); + }), + + onFocusInput: subscribeToQuickEntryEvent(QuickEntryServiceEvent.FocusInput), + onHide: subscribeToQuickEntryEvent(QuickEntryServiceEvent.Hide), + onCreateTaskRequested: subscribeToQuickEntryEvent( + QuickEntryServiceEvent.CreateTaskRequested, + ), +}); diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index d5796c939..55e618871 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -18,6 +18,8 @@ import { isDevBuild } from "./utils/env"; import { logger, readChromiumLogTail } from "./utils/logger"; import { type WindowStateSchema, windowStateStore } from "./utils/store"; +type IPCHandlerHandle = ReturnType; + const log = logger.scope("window"); declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -70,11 +72,20 @@ export function saveWindowState(window: BrowserWindow): void { } let mainWindow: BrowserWindow | null = null; +let ipcHandler: IPCHandlerHandle | null = null; export function getMainWindow(): BrowserWindow | null { return mainWindow; } +export function attachWindowToIPC(window: BrowserWindow): void { + if (!ipcHandler) { + log.warn("attachWindowToIPC called before IPC handler was created"); + return; + } + ipcHandler.attachWindow(window); +} + export function focusMainWindow(reason: string): void { if (mainWindow) { log.info("focusMainWindow called", { @@ -89,6 +100,148 @@ export function focusMainWindow(reason: string): void { } } +export function showAndFocusMainWindow(): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.moveTop(); + mainWindow.focus(); + app.focus({ steal: true }); +} + +// ===== Quick Entry window ===== + +const QUICK_ENTRY_WIDTH = 960; +const QUICK_ENTRY_HEIGHT = 170; +const QUICK_ENTRY_BOTTOM_MARGIN = 120; + +let quickEntryWindow: BrowserWindow | null = null; + +export interface QuickEntryWindowHandlers { + onBlur: () => void; +} + +export function createQuickEntryWindow( + handlers: QuickEntryWindowHandlers, +): void { + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) return; + const isDev = isDevBuild(); + + const window = new BrowserWindow({ + width: QUICK_ENTRY_WIDTH, + height: QUICK_ENTRY_HEIGHT, + show: false, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + hasShadow: true, + roundedCorners: true, + alwaysOnTop: true, + backgroundColor: "#00000000", + webPreferences: { + nodeIntegration: true, + contextIsolation: true, + preload: path.join(__dirname, "preload.js"), + partition: "persist:main", + additionalArguments: isDev + ? ["--posthog-code-dev", "--posthog-quick-entry"] + : ["--posthog-quick-entry"], + ...(isDev && { webSecurity: false }), + }, + }); + + window.setAlwaysOnTop(true, "floating"); + if (process.platform === "darwin") { + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + window.on("blur", () => { + if (!quickEntryWindow || quickEntryWindow.isDestroyed()) return; + handlers.onBlur(); + }); + + window.on("closed", () => { + if (quickEntryWindow === window) { + quickEntryWindow = null; + } + }); + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + window.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}#quick-entry`); + } else { + window.loadFile( + path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), + { hash: "quick-entry" }, + ); + } + + if (ipcHandler) { + ipcHandler.attachWindow(window); + } else { + log.warn("createQuickEntryWindow called before IPC handler exists"); + } + + quickEntryWindow = window; + log.info("Quick entry window created"); +} + +export function isQuickEntryWindowVisible(): boolean { + return ( + !!quickEntryWindow && + !quickEntryWindow.isDestroyed() && + quickEntryWindow.isVisible() + ); +} + +export function isQuickEntryWindowFocused(): boolean { + return ( + !!quickEntryWindow && + !quickEntryWindow.isDestroyed() && + quickEntryWindow.isFocused() + ); +} + +export function showQuickEntryWindow(): boolean { + const window = quickEntryWindow; + if (!window || window.isDestroyed()) { + log.warn("showQuickEntryWindow called before window exists"); + return false; + } + const cursor = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursor); + const { x: dx, y: dy, width: dw, height: dh } = display.workArea; + const x = Math.round(dx + (dw - QUICK_ENTRY_WIDTH) / 2); + const y = Math.round( + dy + dh - QUICK_ENTRY_HEIGHT - QUICK_ENTRY_BOTTOM_MARGIN, + ); + window.setPosition(x, y, false); + + window.show(); + window.focus(); + app.focus({ steal: true }); + return true; +} + +export function hideQuickEntryWindow(): void { + const window = quickEntryWindow; + if (!window || window.isDestroyed()) return; + if (!window.isVisible()) return; + window.hide(); +} + +export function destroyQuickEntryWindow(): void { + const window = quickEntryWindow; + quickEntryWindow = null; + if (window && !window.isDestroyed()) { + window.destroy(); + } +} + function setupExternalLinkHandlers(window: BrowserWindow): void { window.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); @@ -232,7 +385,7 @@ export function createWindow(): void { .get(MAIN_TOKENS.MainWindow) .setMainWindowGetter(() => mainWindow); - createIPCHandler({ + ipcHandler = createIPCHandler({ router: trpcRouter, windows: [mainWindow], }); diff --git a/apps/code/src/renderer/components/GlobalEventHandlers.tsx b/apps/code/src/renderer/components/GlobalEventHandlers.tsx index 8e8842eab..b31864b7b 100644 --- a/apps/code/src/renderer/components/GlobalEventHandlers.tsx +++ b/apps/code/src/renderer/components/GlobalEventHandlers.tsx @@ -6,17 +6,22 @@ import { useSettingsDialogStore } from "@features/settings/stores/settingsDialog import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; -import { useTasks } from "@features/tasks/hooks/useTasks"; +import { useCreateTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import type { CreateTaskRequest } from "@main/services/quick-entry/schemas"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { get } from "@renderer/di/container"; +import { RENDERER_TOKENS } from "@renderer/di/tokens"; +import type { TaskService } from "@renderer/features/task-detail/service/service"; import { useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; +import type { ExecutionMode, Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useSubscription } from "@trpc/tanstack-react-query"; import { clearApplicationStorage } from "@utils/clearStorage"; import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; import { useCallback, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -61,6 +66,7 @@ export function GlobalEventHandlers({ const isWorktreeTask = currentWorkspace?.mode === "worktree"; const { data: allTasks = [] } = useTasks(); + const { invalidateTasks } = useCreateTask(); const sidebarData = useSidebarData({ activeView: view }); const visualTaskOrder = useVisualTaskOrder(sidebarData); @@ -151,6 +157,53 @@ export function GlobalEventHandlers({ log.info("Main access token invalidated for testing"); }, []); + const handleCreateTaskFromQuickEntry = useCallback( + async (data?: CreateTaskRequest) => { + if (!data) return; + const params = data; + try { + const taskService = get(RENDERER_TOKENS.TaskService); + const result = await taskService.createTask( + { + content: params.content, + repoPath: params.repoPath, + workspaceMode: params.workspaceMode, + branch: params.branch, + adapter: params.adapter, + model: params.model ?? undefined, + reasoningLevel: params.reasoningLevel ?? undefined, + executionMode: + (params.executionMode as ExecutionMode | null) ?? undefined, + }, + (output) => { + // Push the new task into the cached list so the sidebar + // updates immediately, then trigger a refetch. + invalidateTasks(output.task); + navigateToTask(output.task); + }, + ); + + if (!result.success) { + const log = logger.scope("global-event-handlers"); + log.error("Quick entry task creation failed", { + failedStep: result.failedStep, + error: result.error, + }); + toast.error("Failed to create task", { + description: result.error, + }); + } + } catch (err) { + const log = logger.scope("global-event-handlers"); + log.error("Quick entry task creation threw", { err }); + toast.error("Failed to create task", { + description: err instanceof Error ? err.message : String(err), + }); + } + }, + [navigateToTask, invalidateTasks], + ); + const globalOptions = { enableOnFormTags: true, enableOnContentEditable: true, @@ -277,5 +330,11 @@ export function GlobalEventHandlers({ }), ); + useSubscription( + trpcReact.quickEntry.onCreateTaskRequested.subscriptionOptions(undefined, { + onData: handleCreateTaskFromQuickEntry, + }), + ); + return null; } diff --git a/apps/code/src/renderer/features/quick-entry/QuickEntryRoot.tsx b/apps/code/src/renderer/features/quick-entry/QuickEntryRoot.tsx new file mode 100644 index 000000000..d7b4897b7 --- /dev/null +++ b/apps/code/src/renderer/features/quick-entry/QuickEntryRoot.tsx @@ -0,0 +1,22 @@ +import { ErrorBoundary } from "@components/ErrorBoundary"; +import { useThemeStore } from "@renderer/stores/themeStore"; +import { useEffect } from "react"; +import { QuickEntryView } from "./QuickEntryView"; + +export function QuickEntryRoot() { + const isDarkMode = useThemeStore((state) => state.isDarkMode); + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDarkMode); + document.documentElement.style.backgroundColor = "transparent"; + document.body.style.backgroundColor = "transparent"; + }, [isDarkMode]); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/code/src/renderer/features/quick-entry/QuickEntryView.tsx b/apps/code/src/renderer/features/quick-entry/QuickEntryView.tsx new file mode 100644 index 000000000..2a2c25b18 --- /dev/null +++ b/apps/code/src/renderer/features/quick-entry/QuickEntryView.tsx @@ -0,0 +1,357 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; +import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; +import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { PromptInput } from "@features/message-editor/components/PromptInput"; +import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; +import type { EditorHandle } from "@features/message-editor/types"; +import { contentToXml } from "@features/message-editor/utils/content"; +import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; +import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { ButtonGroup } from "@posthog/quill"; +import { Flex, Text } from "@radix-ui/themes"; +import { usePreviewConfig } from "@renderer/features/task-detail/hooks/usePreviewConfig"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +const log = logger.scope("quick-entry-view"); +const SESSION_ID = "quick-entry"; + +function hideWindow(): void { + trpcClient.quickEntry.hide.mutate().catch((err) => { + log.warn("Failed to hide quick entry window", { err }); + }); +} + +export function QuickEntryView() { + const trpcReact = useTRPC(); + const editorRef = useRef(null); + const [selectedDirectory, setSelectedDirectory] = useState(""); + const [selectedBranch, setSelectedBranch] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [editorIsEmpty, setEditorIsEmpty] = useState(true); + + const { currentBranch, branchLoading, defaultBranch, busyState } = + useGitQueries(selectedDirectory); + + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + + const { + lastUsedAdapter, + setLastUsedAdapter, + lastUsedWorkspaceMode, + defaultInitialTaskMode, + lastUsedInitialTaskMode, + setLastUsedReasoningEffort, + } = useSettingsStore(); + + const adapter = lastUsedAdapter ?? "claude"; + // Cloud isn't supported from quick entry (no cloud repo picker here). + // Default to worktree so branch selection is meaningful; otherwise use + // the user's preferred local mode. + const effectiveWorkspaceMode: "worktree" | "local" = + lastUsedWorkspaceMode === "cloud" + ? "worktree" + : (lastUsedWorkspaceMode as "worktree" | "local"); + + const { + modeOption, + modelOption, + thoughtOption, + isLoading: isPreviewLoading, + setConfigOption, + } = usePreviewConfig(adapter); + + // Seed default folder once from the most-recently-accessed repository. + useEffect(() => { + if (selectedDirectory) return; + let cancelled = false; + trpcClient.folders.getMostRecentlyAccessedRepository + .query() + .then((repo) => { + if (cancelled || !repo) return; + setSelectedDirectory(repo.path); + }) + .catch(() => { + // ignore — user can still pick manually + }); + return () => { + cancelled = true; + }; + }, [selectedDirectory]); + + // Populate command list for @ file mentions + / skills. + useEffect(() => { + let cancelled = false; + trpcClient.skills.list + .query() + .then((skills) => { + if (cancelled) return; + useDraftStore.getState().actions.setCommands( + SESSION_ID, + skills.map((s) => ({ + name: s.name, + description: s.description, + })), + ); + }) + .catch((err) => { + log.warn("Failed to load skills for quick entry", { err }); + }); + return () => { + cancelled = true; + useDraftStore.getState().actions.clearCommands(SESSION_ID); + }; + }, []); + + useSubscription( + trpcReact.quickEntry.onFocusInput.subscriptionOptions(undefined, { + onData: () => { + editorRef.current?.focus(); + }, + }), + ); + + useSubscription( + trpcReact.quickEntry.onHide.subscriptionOptions(undefined, { + onData: () => { + editorRef.current?.clear(); + setError(null); + }, + }), + ); + + // Reset branch selection when the repo changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only reset when repo changes + useEffect(() => { + setSelectedBranch(null); + }, [selectedDirectory]); + + useHotkeys( + "escape", + () => { + hideWindow(); + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + }, + ); + + const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); + const hints = [ + "@ to add files", + "/ for skills", + hasHistory ? "↑↓ for history" : "", + ] + .filter(Boolean) + .join(", "); + + const handleModeChange = useCallback( + (value: string) => { + if (modeOption) setConfigOption(modeOption.id, value); + }, + [modeOption, setConfigOption], + ); + + const handleModelChange = useCallback( + (value: string) => { + if (modelOption) setConfigOption(modelOption.id, value); + }, + [modelOption, setConfigOption], + ); + + const handleThoughtChange = useCallback( + (value: string) => { + if (thoughtOption) { + setConfigOption(thoughtOption.id, value); + setLastUsedReasoningEffort(value); + } + }, + [thoughtOption, setConfigOption, setLastUsedReasoningEffort], + ); + + const canSubmit = + !!editorRef.current && + !!selectedDirectory && + !editorIsEmpty && + !isSubmitting; + + const handleSubmit = useCallback(async () => { + const editor = editorRef.current; + if (!editor || isSubmitting) return; + + if (!selectedDirectory) { + setError("Pick a folder first"); + return; + } + if (!isAuthenticated) { + setError("Sign in to PostHog Code first"); + return; + } + + const content = editor.getContent(); + const xml = contentToXml(content).trim(); + if (!xml) return; + + const plainText = editor.getText()?.trim(); + if (plainText) { + useTaskInputHistoryStore.getState().addPrompt(plainText); + } + + setError(null); + setIsSubmitting(true); + + try { + const workspaceMode = effectiveWorkspaceMode; + const branchForTaskCreation = + workspaceMode === "worktree" ? selectedBranch : null; + const currentModel = + modelOption?.type === "select" ? modelOption.currentValue : null; + const currentReasoningLevel = + thoughtOption?.type === "select" ? thoughtOption.currentValue : null; + const adapterDefault = adapter === "codex" ? "auto" : "plan"; + const modeFallback = + defaultInitialTaskMode === "last_used" + ? (lastUsedInitialTaskMode ?? adapterDefault) + : adapterDefault; + const currentExecutionMode = + getCurrentModeFromConfigOptions( + modeOption ? [modeOption] : undefined, + ) ?? modeFallback; + + // Hand the request to the main window so it runs the task-creation + // saga in its own renderer context (session store, folder cache, etc.). + await trpcClient.quickEntry.requestCreateTask.mutate({ + content: xml, + repoPath: selectedDirectory, + workspaceMode, + branch: branchForTaskCreation, + adapter, + model: currentModel, + reasoningLevel: currentReasoningLevel, + executionMode: currentExecutionMode, + }); + + editor.clear(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + log.error("Quick entry submit threw", { err }); + } finally { + setIsSubmitting(false); + } + }, [ + isSubmitting, + selectedDirectory, + selectedBranch, + isAuthenticated, + adapter, + effectiveWorkspaceMode, + modelOption, + thoughtOption, + modeOption, + defaultInitialTaskMode, + lastUsedInitialTaskMode, + ]); + + const getPromptHistory = useCallback( + () => useTaskInputHistoryStore.getState().entries.map((e) => e.text), + [], + ); + + if (!isAuthenticated) { + return ( +
+ + Sign in to PostHog Code to use quick entry. + +
+ ); + } + + return ( +
+
+ + + + + + + + + + } + reasoningSelector={ + !isPreviewLoading && ( + + ) + } + getPromptHistory={getPromptHistory} + onEmptyChange={setEditorIsEmpty} + onSubmitClick={() => { + void handleSubmit(); + }} + onSubmit={() => { + if (canSubmit) void handleSubmit(); + }} + /> + + + {error && {error}} +
+
+ ); +} diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index c3a7fce92..7443ee699 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -4,30 +4,35 @@ import "@stores/rendererWindowFocusStore"; import { Providers } from "@components/Providers"; import { preloadHighlighter } from "@pierre/diffs"; import App from "@renderer/App"; +import { QuickEntryRoot } from "@renderer/features/quick-entry/QuickEntryRoot"; import React from "react"; import ReactDOM from "react-dom/client"; import "./styles/globals.css"; -void preloadHighlighter({ - themes: ["github-dark", "github-light"], - langs: [ - "typescript", - "tsx", - "javascript", - "jsx", - "json", - "css", - "html", - "markdown", - "python", - "ruby", - "go", - "rust", - "shell", - "yaml", - "sql", - ], -}); +const isQuickEntry = window.location.hash === "#quick-entry"; + +if (!isQuickEntry) { + void preloadHighlighter({ + themes: ["github-dark", "github-light"], + langs: [ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "css", + "html", + "markdown", + "python", + "ruby", + "go", + "rust", + "shell", + "yaml", + "sql", + ], + }); +} // HACK(@posthog/hedgehog-mode): The package bundles react-dom 18 code that // accesses React 18 internals at module scope. React 19 moved these to @@ -55,17 +60,17 @@ void preloadHighlighter({ } } -document.title = import.meta.env.DEV - ? "PostHog Code (Development)" - : "PostHog Code"; +document.title = isQuickEntry + ? "PostHog Code Quick Entry" + : import.meta.env.DEV + ? "PostHog Code (Development)" + : "PostHog Code"; const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); ReactDOM.createRoot(rootElement).render( - - - + {isQuickEntry ? : } , );