diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1b9a313524b066274e835fc4a37caa0dfa40a2ae..2c9aa9feef0009de69fd06b91d2c0aeccb634539 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,16 +5,18 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; +import { showDesktopPromptDialog } from "./promptDialog"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const PROMPT_CHANNEL = "desktop:prompt"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -36,6 +38,44 @@ let restartTimer: ReturnType | null = null; let isQuitting = false; let desktopProtocolRegistered = false; +interface DesktopContextMenuItem { + id: string; + label: string; + destructive?: boolean; +} + +let destructiveMenuIconCache: Electron.NativeImage | null | undefined; + +function getDestructiveMenuIcon(): Electron.NativeImage | undefined { + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache ?? undefined; + } + try { + const icon = + process.platform === "darwin" + ? nativeImage.createFromNamedImage("trash").resize({ + width: 14, + height: 14, + }) + : nativeImage.createFromPath(resolveIconPath("png") ?? "").resize({ + width: 14, + height: 14, + }); + if (icon.isEmpty()) { + destructiveMenuIconCache = null; + return undefined; + } + if (process.platform === "darwin") { + icon.setTemplateImage(true); + } + destructiveMenuIconCache = icon; + return icon; + } catch { + destructiveMenuIconCache = null; + return undefined; + } +} + protocol.registerSchemesAsPrivileged([ { scheme: DESKTOP_SCHEME, @@ -396,24 +436,84 @@ function registerIpcHandlers(): void { return showDesktopConfirmDialog(message, owner); }); + ipcMain.removeHandler(PROMPT_CHANNEL); + ipcMain.handle(PROMPT_CHANNEL, async (_event, message: unknown, defaultValue: unknown) => { + if (typeof message !== "string") { + return null; + } + + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + return showDesktopPromptDialog( + message, + typeof defaultValue === "string" ? defaultValue : "", + owner, + ); + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const menu = Menu.buildFromTemplate( - items.map((item) => ({ + ipcMain.handle( + CONTEXT_MENU_CHANNEL, + async ( + _event, + items: DesktopContextMenuItem[], + position?: { x: number; y: number }, + ) => { + const normalizedItems = items + .filter((item) => typeof item.id === "string" && typeof item.label === "string") + .map((item) => ({ + id: item.id, label: item.label, - click: () => resolve(item.id), - })), - ); - menu.popup({ - window, - callback: () => resolve(null), + destructive: item.destructive === true, + })); + if (normalizedItems.length === 0) { + return null; + } + + const popupPosition = + position && + Number.isFinite(position.x) && + Number.isFinite(position.y) && + position.x >= 0 && + position.y >= 0 + ? { + x: Math.floor(position.x), + y: Math.floor(position.y), + } + : null; + + const window = BrowserWindow.getFocusedWindow() ?? mainWindow; + if (!window) return null; + + return new Promise((resolve) => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of normalizedItems) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + click: () => resolve(item.id), + }; + if (item.destructive) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); + } + + const menu = Menu.buildFromTemplate(template); + menu.popup({ + window, + ...(popupPosition ?? {}), + callback: () => resolve(null), + }); }); - }); - }); + }, + ); ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b7ee8454cccbf36488dfb39194e0620a8f11400c..c251075e86844c8f58f4e1e03d25ccb574df4ec6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const PROMPT_CHANNEL = "desktop:prompt"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -12,7 +13,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), - showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items), + prompt: (message, defaultValue) => ipcRenderer.invoke(PROMPT_CHANNEL, message, defaultValue), + showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/promptDialog.ts b/apps/desktop/src/promptDialog.ts new file mode 100644 index 0000000000000000000000000000000000000000..81f14dfcaf39c4a4fb966749a2cf440628aef557 --- /dev/null +++ b/apps/desktop/src/promptDialog.ts @@ -0,0 +1,231 @@ +import { randomBytes } from "node:crypto"; +import { BrowserWindow, ipcMain } from "electron"; + +const PROMPT_WIDTH = 420; +const PROMPT_HEIGHT = 188; + +function createPromptHtml(input: { + message: string; + defaultValue: string; + submitChannel: string; + cancelChannel: string; +}): string { + const scriptConfig = JSON.stringify(input); + return ` + + + + + ${input.message} + + + +

+
+ +
+ + +
+
+ + +`; +} + +export async function showDesktopPromptDialog( + message: string, + defaultValue: string, + ownerWindow: BrowserWindow | null, +): Promise { + const normalizedMessage = message.trim(); + if (normalizedMessage.length === 0) { + return null; + } + + const token = randomBytes(12).toString("hex"); + const submitChannel = `desktop:prompt-dialog:submit:${token}`; + const cancelChannel = `desktop:prompt-dialog:cancel:${token}`; + + const promptWindow = new BrowserWindow({ + width: PROMPT_WIDTH, + height: PROMPT_HEIGHT, + useContentSize: true, + show: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + autoHideMenuBar: true, + title: normalizedMessage, + ...(ownerWindow ? { parent: ownerWindow } : {}), + modal: ownerWindow !== null, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + promptWindow.removeMenu(); + + return new Promise((resolve) => { + let settled = false; + + const settle = (value: string | null) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const cleanup = () => { + ipcMain.removeAllListeners(submitChannel); + ipcMain.removeAllListeners(cancelChannel); + if (!promptWindow.isDestroyed()) { + promptWindow.destroy(); + } + }; + + ipcMain.once(submitChannel, (_event, value: unknown) => { + if (typeof value !== "string") { + settle(null); + return; + } + settle(value); + }); + ipcMain.once(cancelChannel, () => settle(null)); + promptWindow.once("closed", () => settle(null)); + + const html = createPromptHtml({ + message: normalizedMessage, + defaultValue, + submitChannel, + cancelChannel, + }); + void promptWindow + .loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + .then(() => { + if (!promptWindow.isDestroyed()) { + promptWindow.show(); + } + }) + .catch(() => settle(null)); + }); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d32b604cf3159097d55dbb6f546ebd7e01a0b148..57b64a1bf8641e40e83698163684b76932cb0c6b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_MODEL, ProjectId, @@ -266,6 +266,10 @@ export default function Sidebar() { const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); + const [renamingThreadId, setRenamingThreadId] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -503,25 +507,74 @@ export default function Sidebar() { setIsPickingFolder(false); }; + const cancelRename = useCallback(() => { + setRenamingThreadId(null); + renamingInputRef.current = null; + }, []); + + const commitRename = useCallback( + async (threadId: ThreadId, newTitle: string, originalTitle: string) => { + const trimmed = newTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + cancelRename(); + return; + } + if (trimmed === originalTitle) { + cancelRename(); + return; + } + const api = readNativeApi(); + if (!api) { + cancelRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + cancelRename(); + }, + [cancelRename], + ); + const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show( [ + { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, - { id: "delete", label: "Delete" }, + { id: "delete", label: "Delete", destructive: true }, ], position, ); - if (clicked === "mark-unread") { - markThreadUnread(threadId); - return; - } - if (clicked !== "delete") return; - const thread = threads.find((t) => t.id === threadId); if (!thread) return; + + if (clicked === "rename") { + setRenamingThreadId(threadId); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadId); + return; + } + if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ @@ -635,7 +688,10 @@ export default function Sidebar() { async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + const clicked = await api.contextMenu.show( + [{ id: "delete", label: "Delete", destructive: true }], + position, + ); if (clicked !== "delete") return; const project = projects.find((entry) => entry.id === projectId); @@ -901,9 +957,41 @@ export default function Sidebar() { {threadStatus.label} )} - - {thread.title} - + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {thread.title} + + )}
{terminalStatus && ( diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 0e020ee3afa59109deb959696b1ea940e19d81cc..3fe6c572e4ea0bdf64c52ac6d530eb7772b5c844 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,7 +4,7 @@ * with the clicked item id, or null if dismissed. */ export function showContextMenuFallback( - items: readonly { id: T; label: string }[], + items: readonly { id: T; label: string; destructive?: boolean }[], position?: { x: number; y: number }, ): Promise { return new Promise((resolve) => { @@ -41,8 +41,8 @@ export function showContextMenuFallback( const btn = document.createElement("button"); btn.type = "button"; btn.textContent = item.label; - const isDeleteAction = item.id === "delete"; - btn.className = isDeleteAction + const isDestructiveAction = item.destructive === true || item.id === "delete"; + btn.className = isDestructiveAction ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; btn.addEventListener("click", () => cleanup(item.id)); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index aa266f8e08610c9015c238b78e3af2563784d664..4e4f61e6d2217e931180120fa8265c61460c337d 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -9,6 +9,12 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); +const showContextMenuFallbackMock = vi.fn< + ( + items: readonly { id: T; label: string; destructive?: boolean }[], + position?: { x: number; y: number }, + ) => Promise +>(); const channelListeners = new Map void>>(); const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( (channel, listener) => { @@ -33,6 +39,10 @@ vi.mock("./wsTransport", () => { }; }); +vi.mock("./contextMenuFallback", () => ({ + showContextMenuFallback: showContextMenuFallbackMock, +})); + function emitPush(channel: string, data: unknown): void { const listeners = channelListeners.get(channel); if (!listeners) return; @@ -41,11 +51,24 @@ function emitPush(channel: string, data: unknown): void { } } +function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { + const testGlobal = globalThis as typeof globalThis & { + window?: Window & typeof globalThis & { desktopBridge?: unknown }; + }; + if (!testGlobal.window) { + testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; + } + return testGlobal.window; +} + beforeEach(() => { vi.resetModules(); requestMock.mockReset(); + showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); + Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); + Reflect.deleteProperty(getWindowForTest(), "prompt"); }); afterEach(() => { @@ -307,4 +330,84 @@ describe("wsNativeApi", () => { toTurnCount: 1, }); }); + + it("forwards context menu metadata to desktop bridge", async () => { + const showContextMenu = vi.fn().mockResolvedValue("delete"); + Object.defineProperty(getWindowForTest(), "desktopBridge", { + configurable: true, + writable: true, + value: { + showContextMenu, + }, + }); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "delete", label: "Delete", destructive: true }, + ], + { x: 200, y: 300 }, + ); + + expect(showContextMenu).toHaveBeenCalledWith( + [ + { id: "rename", label: "Rename thread" }, + { id: "delete", label: "Delete", destructive: true }, + ], + { x: 200, y: 300 }, + ); + }); + + it("uses fallback context menu when desktop bridge is unavailable", async () => { + showContextMenuFallbackMock.mockResolvedValue("delete"); + Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + await api.contextMenu.show( + [{ id: "delete", label: "Delete", destructive: true }], + { x: 20, y: 30 }, + ); + + expect(showContextMenuFallbackMock).toHaveBeenCalledWith( + [{ id: "delete", label: "Delete", destructive: true }], + { x: 20, y: 30 }, + ); + }); + + it("forwards prompt calls to desktop bridge", async () => { + const prompt = vi.fn().mockResolvedValue("renamed"); + Object.defineProperty(getWindowForTest(), "desktopBridge", { + configurable: true, + writable: true, + value: { + prompt, + }, + }); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + const value = await api.dialogs.prompt("Rename thread", "Old title"); + + expect(value).toBe("renamed"); + expect(prompt).toHaveBeenCalledWith("Rename thread", "Old title"); + }); + + it("uses browser prompt fallback when desktop bridge is unavailable", async () => { + const prompt = vi.fn().mockReturnValue("renamed"); + Object.defineProperty(getWindowForTest(), "prompt", { + configurable: true, + writable: true, + value: prompt, + }); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + const value = await api.dialogs.prompt("Rename thread", "Old title"); + + expect(value).toBe("renamed"); + expect(prompt).toHaveBeenCalledWith("Rename thread", "Old title"); + }); }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f7ac04a9b07a6b4b10dc3214ea53b41b067a7e31..aa6830022b91daa126c6c72091d40708ef7d2cd6 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -124,6 +124,16 @@ export function createWsNativeApi(): NativeApi { } return window.confirm(message); }, + prompt: async (message, defaultValue) => { + if (window.desktopBridge) { + return window.desktopBridge.prompt(message, defaultValue); + } + try { + return window.prompt(message, defaultValue ?? ""); + } catch { + return null; + } + }, }, terminal: { open: (input) => transport.request(WS_METHODS.terminalOpen, input), @@ -171,11 +181,11 @@ export function createWsNativeApi(): NativeApi { }, contextMenu: { show: async ( - items: readonly { id: T; label: string }[], + items: readonly { id: T; label: string; destructive?: boolean }[], position?: { x: number; y: number }, ): Promise => { if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items) as Promise; + return window.desktopBridge.showContextMenu(items, position) as Promise; } return showContextMenuFallback(items, position); }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ce1c0305a54a918515205b55ba98a2fcfad11c0b..bf12d8cb5d8be47bb388edeebd7de6af74e538e2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -37,12 +37,19 @@ import type { } from "./orchestration"; import { EditorId } from "./editor"; +export interface ContextMenuItem { + id: T; + label: string; + destructive?: boolean; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; confirm: (message: string) => Promise; + prompt: (message: string, defaultValue?: string) => Promise; showContextMenu: ( - items: readonly { id: T; label: string }[], + items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; @@ -53,6 +60,7 @@ export interface NativeApi { dialogs: { pickFolder: () => Promise; confirm: (message: string) => Promise; + prompt: (message: string, defaultValue?: string) => Promise; }; terminal: { open: (input: TerminalOpenInput) => Promise; @@ -85,7 +93,7 @@ export interface NativeApi { }; contextMenu: { show: ( - items: readonly { id: T; label: string }[], + items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; };