From 169d06f05b543433278a123b3512fddad05f9f2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Feb 2026 13:36:03 -0800 Subject: [PATCH] Add context menu to delete threads from sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds right-click → Delete on sidebar thread items using Electron's native Menu.popup() when running in desktop, with an imperative DOM fallback for browser environments. The contextMenu.show() API is generic so the returned item id is type-safe based on the items passed. Co-Authored-By: Claude Haiku 4.5 --- apps/desktop/src/main.ts | 22 +++++++++- apps/desktop/src/preload.ts | 3 ++ apps/web/src/components/Sidebar.tsx | 32 +++++++++++++++ apps/web/src/contextMenuFallback.ts | 64 +++++++++++++++++++++++++++++ apps/web/src/store.ts | 12 +++++- apps/web/src/vite-env.d.ts | 1 + apps/web/src/wsNativeApi.ts | 12 ++++++ packages/contracts/src/ipc.ts | 6 +++ 8 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/contextMenuFallback.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3161a2e9dc1d46e24665ea70d14ca7ec09b6933c..1315aeb08ad635c8bb07a2d64693e7b2bcc1bae0 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.js"); 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 82568b4f9fbd623b3800feabee7094b5559fc9ea..45dcd74d915491bab97e9cb0ac20078f85194fdf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -160,6 +160,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 = @@ -290,6 +315,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 f9b075b89e015391082b5c7e8139591613f8aa54..e5b4e9f8eb39ecc65ba2670d187adcd0e032dfad 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -33,7 +33,8 @@ type Action = | { type: "SET_ERROR"; threadId: string; error: string | null } | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | { type: "SET_THREAD_MODEL"; threadId: string; model: string } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; + | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } + | { type: "DELETE_THREAD"; threadId: string }; // ── State ──────────────────────────────────────────────────────────── @@ -375,6 +376,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 1ed5d5d2a1f6c47712e22ef4e4f8f22083232405..257efa47a0f8dcfa41a6dd921800ea6cac4cf0c3 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; @@ -95,6 +96,17 @@ export function createWsNativeApi(): NativeApi { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), }, + 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 4138750510ceb684c46626ae3ba4dbe92583d4d5..e1f748f835df64223b4184822045ff481b4ab2c8 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -62,4 +62,10 @@ export interface NativeApi { shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; }; + contextMenu: { + show: ( + items: readonly { id: T; label: string }[], + position?: { x: number; y: number }, + ) => Promise; + }; } -- 2.54.0