| 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,13 +5,14 @@ import net from "node:net"; |
| 5 | import os from "node:os"; | 5 | import os from "node:os"; |
| 6 | import path from "node:path"; | 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 | import { fixPath } from "./fixPath"; | 10 | import { fixPath } from "./fixPath"; |
| 11 | 11 | ||
| 12 | fixPath(); | 12 | fixPath(); |
| 13 | 13 | ||
| 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | ||
| 15 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | 16 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 16 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); | 17 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); |
| 17 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | 18 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { | ... | @@ -139,6 +140,25 @@ function registerIpcHandlers(): void { |
| 139 | if (result.canceled) return null; | 140 | if (result.canceled) return null; |
| 140 | return result.filePaths[0] ?? null; | 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 | function createWindow(): BrowserWindow { | 164 | function createWindow(): BrowserWindow { |
apps/desktop/src/preload.ts+3| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { contextBridge, ipcRenderer } from "electron"; | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | ||
| 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | ||
| 4 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; | 5 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 5 | 6 | ||
| 6 | contextBridge.exposeInMainWorld("desktopBridge", { | 7 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 7 | getWsUrl: () => wsUrl, | 8 | getWsUrl: () => wsUrl, |
| 8 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | 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,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 | useEffect(() => { | 188 | useEffect(() => { |
| 164 | const onWindowKeyDown = (event: KeyboardEvent) => { | 189 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 165 | const isNewThreadShortcut = | 190 | const isNewThreadShortcut = |
| ... | @@ -290,6 +315,13 @@ export default function Sidebar() { | ... | @@ -290,6 +315,13 @@ export default function Sidebar() { |
| 290 | threadId: thread.id, | 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 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 326 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 295 | {threadStatus && ( | 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,7 +33,8 @@ type Action = |
| 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 35 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | 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 | // ── State ──────────────────────────────────────────────────────────── | 39 | // ── State ──────────────────────────────────────────────────────────── |
| 39 | 40 | ||
| ... | @@ -375,6 +376,15 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -375,6 +376,15 @@ export function reducer(state: AppState, action: Action): AppState { |
| 375 | runtimeMode: action.mode, | 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 | default: | 388 | default: |
| 379 | return state; | 389 | return state; |
| 380 | } | 390 | } |
apps/web/src/vite-env.d.ts+1| ... | @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; | ... | @@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts"; |
| 5 | interface DesktopBridge { | 5 | interface DesktopBridge { |
| 6 | getWsUrl: () => string | null; | 6 | getWsUrl: () => string | null; |
| 7 | pickFolder: () => Promise<string | null>; | 7 | pickFolder: () => Promise<string | null>; |
| 8 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | ||
| 8 | } | 9 | } |
| 9 | 10 | ||
| 10 | declare global { | 11 | declare global { |
apps/web/src/wsNativeApi.ts+12| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | ||
| 3 | import { WsTransport } from "./wsTransport"; | 4 | import { WsTransport } from "./wsTransport"; |
| 4 | 5 | ||
| 5 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | 6 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| ... | @@ -95,6 +96,17 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -95,6 +96,17 @@ export function createWsNativeApi(): NativeApi { |
| 95 | openInEditor: (cwd, editor) => | 96 | openInEditor: (cwd, editor) => |
| 96 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | 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 | instance = { api, transport }; | 112 | instance = { api, transport }; |
packages/contracts/src/ipc.ts+6| ... | @@ -62,4 +62,10 @@ export interface NativeApi { | ... | @@ -62,4 +62,10 @@ export interface NativeApi { |
| 62 | shell: { | 62 | shell: { |
| 63 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | 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 | } |