diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 66e4b8a330dbb4469051d6c3272f6f2e008a1ca0..de9e839fdc56f95fb317bad9f653c79b91a1d56e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,13 +5,14 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron"; import { fixPath } from "./fixPath"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const ROOT_DIR = path.resolve(__dirname, "../../.."); const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { if (result.canceled) return null; return result.filePaths[0] ?? null; }); + + 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) => ({ + label: item.label, + click: () => resolve(item.id), + })), + ); + menu.popup({ + window, + callback: () => resolve(null), + }); + }); + }); } function createWindow(): BrowserWindow { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6fac7916bb7ac33d1a3185b117b2b06307a3b6b0..47041b5611e93c6665e1c6718d94c17fb4871d57 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,9 +1,12 @@ import { contextBridge, ipcRenderer } from "electron"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise, + showContextMenu: (items: { id: string; label: string }[]) => + ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d3789c66671daa536c8bae3c41aa299ce0bb78e0..ed6f7bd5da9b14d5af5d6571b4eb725bc16c7785 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -162,6 +162,31 @@ export default function Sidebar() { } }; + const handleThreadContextMenu = useCallback( + async (threadId: string, position: { x: number; y: number }) => { + if (!api) return; + const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + if (clicked !== "delete") return; + + const thread = state.threads.find((t) => t.id === threadId); + if (!thread) return; + + // Stop active session if running + if (thread.session?.sessionId) { + try { + await api.providers.stopSession({ + sessionId: thread.session.sessionId, + }); + } catch { + // Session may already be stopped + } + } + + dispatch({ type: "DELETE_THREAD", threadId }); + }, + [api, dispatch, state.threads], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const isNewThreadShortcut = @@ -292,6 +317,13 @@ export default function Sidebar() { threadId: thread.id, }) } + onContextMenu={(e) => { + e.preventDefault(); + void handleThreadContextMenu(thread.id, { + x: e.clientX, + y: e.clientY, + }); + }} >
{threadStatus && ( diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dcbb13be582b85800b191a23849a66f0785157f --- /dev/null +++ b/apps/web/src/contextMenuFallback.ts @@ -0,0 +1,64 @@ +/** + * Imperative DOM-based context menu for non-Electron environments. + * Shows a positioned dropdown and returns a promise that resolves + * with the clicked item id, or null if dismissed. + */ +export function showContextMenuFallback( + items: readonly { id: T; label: string }[], + position?: { x: number; y: number }, +): Promise { + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; + + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + + const x = position?.x ?? 0; + const y = position?.y ?? 0; + menu.style.top = `${y}px`; + menu.style.left = `${x}px`; + + function cleanup(result: T | null) { + document.removeEventListener("keydown", onKeyDown); + overlay.remove(); + menu.remove(); + resolve(result); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + cleanup(null); + } + } + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + + for (const item of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = item.label; + btn.className = + "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; + btn.addEventListener("click", () => cleanup(item.id)); + menu.appendChild(btn); + } + + document.body.appendChild(overlay); + document.body.appendChild(menu); + + // Adjust if menu overflows viewport + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${window.innerWidth - rect.width - 4}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${window.innerHeight - rect.height - 4}px`; + } + }); + }); +} diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2ee930fbb95d034fde40a3bee5a1a762f6600d30..5f9c6165f950123b70e17d49847f557116935e96 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -39,7 +39,8 @@ type Action = branch: string | null; worktreePath: string | null; } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; + | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } + | { type: "DELETE_THREAD"; threadId: string }; // ── State ──────────────────────────────────────────────────────────── @@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState { runtimeMode: action.mode, }; + case "DELETE_THREAD": { + const threads = state.threads.filter((t) => t.id !== action.threadId); + const activeThreadId = + state.activeThreadId === action.threadId + ? (threads[0]?.id ?? null) + : state.activeThreadId; + return { ...state, threads, activeThreadId }; + } + default: return state; } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 0d83bb7e8c8421139cfeaffd58bbdc30e71f93d1..db5614c39c8adaa28ef7a994ecd209bd60a85ffb 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; + showContextMenu: (items: readonly { id: string; label: string }[]) => Promise; } declare global { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 7e8b3ad02814035da230396e01a24b5c731c6240..c837bcea162efcba175dcef10b9e36078d36563f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,5 +1,6 @@ import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; +import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; @@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi { checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), init: (input) => transport.request(WS_METHODS.gitInit, input), }, + contextMenu: { + show: async ( + items: readonly { id: T; label: string }[], + position?: { x: number; y: number }, + ): Promise => { + if (window.desktopBridge) { + return window.desktopBridge.showContextMenu(items) as Promise; + } + return showContextMenuFallback(items, position); + }, + }, }; instance = { api, transport }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0070e3100288134e1b6ddaba02db7188fa382d75..1681a0cafdfc0302a302bbef94400d870a953bf2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -80,4 +80,10 @@ export interface NativeApi { checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; }; + contextMenu: { + show: ( + items: readonly { id: T; label: string }[], + position?: { x: number; y: number }, + ) => Promise; + }; }