authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 13:36:03-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-11 13:36:32-08:00
log169d06f05b543433278a123b3512fddad05f9f2a
tree42cffede056f8464e43185582c21058707ff68f6
parent9023d139295fb14035424c93b9b0f30eb77e1d1d

Add context menu to delete threads from sidebar

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";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain } from "electron";
8import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron";
99
1010import { fixPath } from "./fixPath";
1111
1212fixPath();
1313
1414const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1516const ROOT_DIR = path.resolve(__dirname, "../../..");
1617const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js");
1718const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
......@@ -139,6 +140,25 @@ function registerIpcHandlers(): void {
139140 if (result.canceled) return null;
140141 return result.filePaths[0] ?? null;
141142 });
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 });
142162}
143163
144164function createWindow(): BrowserWindow {
apps/desktop/src/preload.ts+3
......@@ -1,9 +1,12 @@
11import { contextBridge, ipcRenderer } from "electron";
22
33const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
45const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
56
67contextBridge.exposeInMainWorld("desktopBridge", {
78 getWsUrl: () => wsUrl,
89 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>,
912});
apps/web/src/components/Sidebar.tsx+32
......@@ -160,6 +160,31 @@ export default function Sidebar() {
160160 }
161161 };
162162
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
163188 useEffect(() => {
164189 const onWindowKeyDown = (event: KeyboardEvent) => {
165190 const isNewThreadShortcut =
......@@ -290,6 +315,13 @@ export default function Sidebar() {
290315 threadId: thread.id,
291316 })
292317 }
318 onContextMenu={(e) => {
319 e.preventDefault();
320 void handleThreadContextMenu(thread.id, {
321 x: e.clientX,
322 y: e.clientY,
323 });
324 }}
293325 >
294326 <div className="flex min-w-0 flex-1 items-center gap-1.5">
295327 {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 */
6export 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 =
3333 | { type: "SET_ERROR"; threadId: string; error: string | null }
3434 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
3535 | { 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 };
3738
3839// ── State ────────────────────────────────────────────────────────────
3940
......@@ -375,6 +376,15 @@ export function reducer(state: AppState, action: Action): AppState {
375376 runtimeMode: action.mode,
376377 };
377378
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
378388 default:
379389 return state;
380390 }
apps/web/src/vite-env.d.ts+1
......@@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts";
55interface DesktopBridge {
66 getWsUrl: () => string | null;
77 pickFolder: () => Promise<string | null>;
8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
89}
910
1011declare global {
apps/web/src/wsNativeApi.ts+12
......@@ -1,5 +1,6 @@
11import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
22
3import { showContextMenuFallback } from "./contextMenuFallback";
34import { WsTransport } from "./wsTransport";
45
56let instance: { api: NativeApi; transport: WsTransport } | null = null;
......@@ -95,6 +96,17 @@ export function createWsNativeApi(): NativeApi {
9596 openInEditor: (cwd, editor) =>
9697 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
9798 },
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 },
98110 };
99111
100112 instance = { api, transport };
packages/contracts/src/ipc.ts+6
......@@ -62,4 +62,10 @@ export interface NativeApi {
6262 shell: {
6363 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
6464 };
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 };
6571}