| author | |
| committer | |
| log | 169d06f05b543433278a123b3512fddad05f9f2a |
| tree | 42cffede056f8464e43185582c21058707ff68f6 |
| parent | 9023d139295fb14035424c93b9b0f30eb77e1d1d |
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 <noreply@anthropic.com>8 files changed, 150 insertions(+), 2 deletions(-)
apps/desktop/src/main.ts+21-1| ... | ... | @@ -5,13 +5,14 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain } from "electron"; | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron"; | |
| 9 | 9 | |
| 10 | 10 | import { fixPath } from "./fixPath"; |
| 11 | 11 | |
| 12 | 12 | fixPath(); |
| 13 | 13 | |
| 14 | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 15 | 16 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 16 | 17 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); |
| 17 | 18 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { |
| 139 | 140 | if (result.canceled) return null; |
| 140 | 141 | return result.filePaths[0] ?? null; |
| 141 | 142 | }); |
| 143 | ||
| 144 | ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); | |
| 145 | ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { | |
| 146 | const window = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 147 | if (!window) return null; | |
| 148 | ||
| 149 | return new Promise<string | null>((resolve) => { | |
| 150 | const menu = Menu.buildFromTemplate( | |
| 151 | items.map((item) => ({ | |
| 152 | label: item.label, | |
| 153 | click: () => resolve(item.id), | |
| 154 | })), | |
| 155 | ); | |
| 156 | menu.popup({ | |
| 157 | window, | |
| 158 | callback: () => resolve(null), | |
| 159 | }); | |
| 160 | }); | |
| 161 | }); | |
| 142 | 162 | } |
| 143 | 163 | |
| 144 | 164 | function createWindow(): BrowserWindow { |
apps/desktop/src/preload.ts+3| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | |
| 3 | 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 4 | 5 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 5 | 6 | |
| 6 | 7 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 7 | 8 | getWsUrl: () => wsUrl, |
| 8 | 9 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, |
| 10 | showContextMenu: (items: { id: string; label: string }[]) => | |
| 11 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, | |
| 9 | 12 | }); |
apps/web/src/components/Sidebar.tsx+32| ... | ... | @@ -160,6 +160,31 @@ export default function Sidebar() { |
| 160 | 160 | } |
| 161 | 161 | }; |
| 162 | 162 | |
| 163 | const handleThreadContextMenu = useCallback( | |
| 164 | async (threadId: string, position: { x: number; y: number }) => { | |
| 165 | if (!api) return; | |
| 166 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | |
| 167 | if (clicked !== "delete") return; | |
| 168 | ||
| 169 | const thread = state.threads.find((t) => t.id === threadId); | |
| 170 | if (!thread) return; | |
| 171 | ||
| 172 | // Stop active session if running | |
| 173 | if (thread.session?.sessionId) { | |
| 174 | try { | |
| 175 | await api.providers.stopSession({ | |
| 176 | sessionId: thread.session.sessionId, | |
| 177 | }); | |
| 178 | } catch { | |
| 179 | // Session may already be stopped | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | dispatch({ type: "DELETE_THREAD", threadId }); | |
| 184 | }, | |
| 185 | [api, dispatch, state.threads], | |
| 186 | ); | |
| 187 | ||
| 163 | 188 | useEffect(() => { |
| 164 | 189 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 165 | 190 | const isNewThreadShortcut = |
| ... | ... | @@ -290,6 +315,13 @@ export default function Sidebar() { |
| 290 | 315 | threadId: thread.id, |
| 291 | 316 | }) |
| 292 | 317 | } |
| 318 | onContextMenu={(e) => { | |
| 319 | e.preventDefault(); | |
| 320 | void handleThreadContextMenu(thread.id, { | |
| 321 | x: e.clientX, | |
| 322 | y: e.clientY, | |
| 323 | }); | |
| 324 | }} | |
| 293 | 325 | > |
| 294 | 326 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 295 | 327 | {threadStatus && ( |
apps/web/src/contextMenuFallback.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | /** | |
| 2 | * Imperative DOM-based context menu for non-Electron environments. | |
| 3 | * Shows a positioned dropdown and returns a promise that resolves | |
| 4 | * with the clicked item id, or null if dismissed. | |
| 5 | */ | |
| 6 | export function showContextMenuFallback<T extends string>( | |
| 7 | items: readonly { id: T; label: string }[], | |
| 8 | position?: { x: number; y: number }, | |
| 9 | ): Promise<T | null> { | |
| 10 | return new Promise<T | null>((resolve) => { | |
| 11 | const overlay = document.createElement("div"); | |
| 12 | overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; | |
| 13 | ||
| 14 | const menu = document.createElement("div"); | |
| 15 | menu.className = | |
| 16 | "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; | |
| 17 | ||
| 18 | const x = position?.x ?? 0; | |
| 19 | const y = position?.y ?? 0; | |
| 20 | menu.style.top = `${y}px`; | |
| 21 | menu.style.left = `${x}px`; | |
| 22 | ||
| 23 | function cleanup(result: T | null) { | |
| 24 | document.removeEventListener("keydown", onKeyDown); | |
| 25 | overlay.remove(); | |
| 26 | menu.remove(); | |
| 27 | resolve(result); | |
| 28 | } | |
| 29 | ||
| 30 | function onKeyDown(e: KeyboardEvent) { | |
| 31 | if (e.key === "Escape") { | |
| 32 | e.preventDefault(); | |
| 33 | cleanup(null); | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | overlay.addEventListener("mousedown", () => cleanup(null)); | |
| 38 | document.addEventListener("keydown", onKeyDown); | |
| 39 | ||
| 40 | for (const item of items) { | |
| 41 | const btn = document.createElement("button"); | |
| 42 | btn.type = "button"; | |
| 43 | btn.textContent = item.label; | |
| 44 | btn.className = | |
| 45 | "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; | |
| 46 | btn.addEventListener("click", () => cleanup(item.id)); | |
| 47 | menu.appendChild(btn); | |
| 48 | } | |
| 49 | ||
| 50 | document.body.appendChild(overlay); | |
| 51 | document.body.appendChild(menu); | |
| 52 | ||
| 53 | // Adjust if menu overflows viewport | |
| 54 | requestAnimationFrame(() => { | |
| 55 | const rect = menu.getBoundingClientRect(); | |
| 56 | if (rect.right > window.innerWidth) { | |
| 57 | menu.style.left = `${window.innerWidth - rect.width - 4}px`; | |
| 58 | } | |
| 59 | if (rect.bottom > window.innerHeight) { | |
| 60 | menu.style.top = `${window.innerHeight - rect.height - 4}px`; | |
| 61 | } | |
| 62 | }); | |
| 63 | }); | |
| 64 | } |
apps/web/src/store.ts+11-1| ... | ... | @@ -33,7 +33,8 @@ type Action = |
| 33 | 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 34 | 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 35 | 35 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } |
| 36 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | |
| 36 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | |
| 37 | | { type: "DELETE_THREAD"; threadId: string }; | |
| 37 | 38 | |
| 38 | 39 | // ── State ──────────────────────────────────────────────────────────── |
| 39 | 40 | |
| ... | ... | @@ -375,6 +376,15 @@ export function reducer(state: AppState, action: Action): AppState { |
| 375 | 376 | runtimeMode: action.mode, |
| 376 | 377 | }; |
| 377 | 378 | |
| 379 | case "DELETE_THREAD": { | |
| 380 | const threads = state.threads.filter((t) => t.id !== action.threadId); | |
| 381 | const activeThreadId = | |
| 382 | state.activeThreadId === action.threadId | |
| 383 | ? (threads[0]?.id ?? null) | |
| 384 | : state.activeThreadId; | |
| 385 | return { ...state, threads, activeThreadId }; | |
| 386 | } | |
| 387 | ||
| 378 | 388 | default: |
| 379 | 389 | return state; |
| 380 | 390 | } |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; |
| 5 | 5 | interface DesktopBridge { |
| 6 | 6 | getWsUrl: () => string | null; |
| 7 | 7 | pickFolder: () => Promise<string | null>; |
| 8 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | |
| 8 | 9 | } |
| 9 | 10 | |
| 10 | 11 | declare global { |
apps/web/src/wsNativeApi.ts+12| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | |
| 3 | 4 | import { WsTransport } from "./wsTransport"; |
| 4 | 5 | |
| 5 | 6 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| ... | ... | @@ -95,6 +96,17 @@ export function createWsNativeApi(): NativeApi { |
| 95 | 96 | openInEditor: (cwd, editor) => |
| 96 | 97 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), |
| 97 | 98 | }, |
| 99 | contextMenu: { | |
| 100 | show: async <T extends string>( | |
| 101 | items: readonly { id: T; label: string }[], | |
| 102 | position?: { x: number; y: number }, | |
| 103 | ): Promise<T | null> => { | |
| 104 | if (window.desktopBridge) { | |
| 105 | return window.desktopBridge.showContextMenu(items) as Promise<T | null>; | |
| 106 | } | |
| 107 | return showContextMenuFallback(items, position); | |
| 108 | }, | |
| 109 | }, | |
| 98 | 110 | }; |
| 99 | 111 | |
| 100 | 112 | instance = { api, transport }; |
packages/contracts/src/ipc.ts+6| ... | ... | @@ -62,4 +62,10 @@ export interface NativeApi { |
| 62 | 62 | shell: { |
| 63 | 63 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| 64 | 64 | }; |
| 65 | contextMenu: { | |
| 66 | show: <T extends string>( | |
| 67 | items: readonly { id: T; label: string }[], | |
| 68 | position?: { x: number; y: number }, | |
| 69 | ) => Promise<T | null>; | |
| 70 | }; | |
| 65 | 71 | } |