Skip to content

Add native context menu to delete threads#12

Merged
juliusmarminge merged 1 commit intomainfrom
juliusmarminge/sidebar-delete-thread
Feb 11, 2026
Merged

Add native context menu to delete threads#12
juliusmarminge merged 1 commit intomainfrom
juliusmarminge/sidebar-delete-thread

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Feb 11, 2026

Summary

  • Right-click on threads in the sidebar to delete them with a native context menu
  • Automatically stops any active provider session before removing the thread
  • Uses Electron's native Menu API for platform-consistent UI

Test plan

  • Right-click a thread in the sidebar
  • Verify native context menu appears with "Delete" option
  • Click Delete and confirm the thread is removed
  • Test with active sessions to verify they're stopped before deletion

🤖 Generated with Claude Code


Open with Devin

Summary by CodeRabbit

  • New Features
    • Right-click context menus added on desktop and web (with a DOM fallback for non-desktop environments).
    • Users can delete threads from the context menu.
    • Active sessions are stopped (if present) before a thread is removed.
    • After deletion, active thread selection is reassigned automatically or cleared.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a cross-platform context menu: Electron main IPC handler and preload bridge, contract/type additions, web fallback UI, and web UI + store updates to show a thread context menu and delete threads when "Delete" is selected.

Changes

Cohort / File(s) Summary
Desktop main & preload
apps/desktop/src/main.ts, apps/desktop/src/preload.ts
Add CONTEXT_MENU_CHANNEL ("desktop:context-menu") and import Menu in main; handle IPC invoke by building a native Menu, showing a popup, and resolving selected id or null. Expose showContextMenu(items) on desktopBridge in preload.
Shared contract types
packages/contracts/src/ipc.ts
Extend NativeApi with `contextMenu.show(items: readonly { id: T; label: string }[], position?: { x: number; y: number }) => Promise<T
Web bridge, fallback & types
apps/web/src/wsNativeApi.ts, apps/web/src/contextMenuFallback.ts, apps/web/src/vite-env.d.ts
Add api.contextMenu.show which calls window.desktopBridge.showContextMenu(items) when available, otherwise uses showContextMenuFallback(items, position). Implement DOM fallback showContextMenuFallback and add showContextMenu to DesktopBridge types.
Web UI & state
apps/web/src/components/Sidebar.tsx, apps/web/src/store.ts
Wire onContextMenu on thread items to call contextMenu.show; handle selection of "Delete" by stopping active session (if any) and dispatching DELETE_THREAD. Add DELETE_THREAD action and reducer case to remove the thread and adjust activeThreadId.
Manifest
package.json
Minor package manifest edits (small line changes).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Sidebar as "Web UI (Sidebar)"
    participant API as "NativeApi / Bridge"
    participant Main as "Electron Main"
    participant Store as "App Store"

    User->>Sidebar: Right-click thread (x,y)
    Sidebar->>API: contextMenu.show(items, position)
    alt Desktop bridge available
        API->>Main: IPC invoke "desktop:context-menu" with items
        Main->>Main: Build native Menu and popup at focused window
        User->>Main: Select option or dismiss
        Main-->>API: Resolve selected item id or null
    else Web fallback
        API->>API: showContextMenuFallback(items, position)
        User->>API: Select option or dismiss
        API-->>Sidebar: Resolve selected item id or null
    end
    Sidebar->>Sidebar: If selected "Delete": stop session (if any)
    Sidebar->>Store: Dispatch DELETE_THREAD(threadId)
    Store->>Store: Remove thread, reassign activeThreadId if needed
    Store-->>Sidebar: Updated state
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add native context menu to delete threads' directly and accurately reflects the main change: introducing a native context menu feature for deleting threads in the sidebar.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch juliusmarminge/sidebar-delete-thread

Comment @coderabbitai help to get the list of available commands and usage tips.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Feb 11, 2026

Add a native context menu for thread deletion and wire right-click in Sidebar to invoke NativeApi.contextMenu.show via Electron IPC in Sidebar.tsx and main.ts

Introduce desktop:context-menu IPC and expose window.desktopBridge.showContextMenu; update NativeApi with contextMenu.show; handle thread deletion and session stop on selection in Sidebar.tsx.

📍Where to Start

Start with the registerIpcHandlers addition for desktop:context-menu in main.ts, then follow the bridge in preload.ts and the consumer in Sidebar.tsx.


Macroscope summarized eef2901.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main.ts`:
- Line 6: Replace usages of BrowserWindow.getFocusedWindow() when building/popup
context menus in the ipc handler with
BrowserWindow.fromWebContents(event.sender) so the menu is resolved from the IPC
sender's webContents; update the IPC handler that receives the menu payload (the
ipcMain.handle callback) to validate/parse the incoming array into the shared
ContextMenuItem type using the existing IPC validation utility before mapping to
Menu.buildFromTemplate, and pass BrowserWindow.fromWebContents(event.sender)
into Menu.popup to avoid null or wrong focused-window cases.

In `@apps/renderer/src/components/Sidebar.tsx`:
- Around line 87-112: handleThreadContextMenu currently swallows errors from
api.providers.stopSession and proceeds to dispatch DELETE_THREAD, which can
orphan an active session; change the flow in handleThreadContextMenu so that if
stopSession throws you do not delete the thread unless you can detect a harmless
"already stopped" condition—catch the error from api.providers.stopSession,
check the error message/code for an “already stopped” sentinel (or otherwise
surface the error via UI/api.logger) and abort the dispatch({ type:
"DELETE_THREAD", ... }) when stopSession failed, only allowing deletion when
stopSession succeeded or the error explicitly indicates the session was already
stopped; reference handleThreadContextMenu, api.providers.stopSession, and the
DELETE_THREAD dispatch to locate and update the logic.

In `@apps/renderer/src/store.ts`:
- Around line 45-46: The DELETE_THREAD action currently falls back to the first
remaining thread globally which can switch projects; change the reducer logic
that handles { type: "DELETE_THREAD"; threadId: string } so that if the deleted
thread equals the current active thread (activeThreadId) you find the next
active thread from the same project only (e.g., filter threads by projectId ===
deletedThread.projectId) and set activeThreadId to that thread's id or null if
none; apply the same same-project fallback change to the other deletion-handling
block referenced (the logic duplicated around the other DELETE_THREAD handling
at the second location).

Comment thread apps/desktop/src/main.ts Outdated
if (isQuitting || restartTimer) return;
import { spawn } from "node:child_process";
import path from "node:path";
import { BrowserWindow, Menu, app, dialog, ipcMain, session, shell } from "electron";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Resolve the menu window from the IPC sender to avoid null focused-window cases.
BrowserWindow.getFocusedWindow() can be null or the wrong window in multi-window or background scenarios; using the sender’s webContents is safer. Also consider using the shared ContextMenuItem type and parsing payloads via the existing IPC validation pattern to avoid malformed item arrays.

🔧 Suggested update
-import {
-  EDITORS,
-  IPC_CHANNELS,
-  type TerminalCommandInput,
-  type TerminalCommandResult,
-  agentConfigSchema,
-  agentSessionIdSchema,
-  newTodoInputSchema,
-  providerInterruptTurnInputSchema,
-  providerRespondToRequestInputSchema,
-  providerSendTurnInputSchema,
-  providerSessionStartInputSchema,
-  providerStopSessionInputSchema,
-  terminalCommandInputSchema,
-  todoIdSchema,
-} from "@acme/contracts";
+import {
+  EDITORS,
+  IPC_CHANNELS,
+  type ContextMenuItem,
+  type TerminalCommandInput,
+  type TerminalCommandResult,
+  agentConfigSchema,
+  agentSessionIdSchema,
+  newTodoInputSchema,
+  providerInterruptTurnInputSchema,
+  providerRespondToRequestInputSchema,
+  providerSendTurnInputSchema,
+  providerSessionStartInputSchema,
+  providerStopSessionInputSchema,
+  terminalCommandInputSchema,
+  todoIdSchema,
+} from "@acme/contracts";
@@
-  ipcMain.handle(IPC_CHANNELS.contextMenuShow, async (_event, items: { id: string; label: string }[]) => {
-    const window = BrowserWindow.getFocusedWindow();
+  ipcMain.handle(IPC_CHANNELS.contextMenuShow, async (event, items: ContextMenuItem[]) => {
+    const window =
+      BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow();
     if (!window) return null;
Electron BrowserWindow.fromWebContents usage for Menu.popup — recommended window selection when showing a context menu from ipcMain.handle?

Also applies to: 152-169

🤖 Prompt for AI Agents
In `@apps/desktop/src/main.ts` at line 6, Replace usages of
BrowserWindow.getFocusedWindow() when building/popup context menus in the ipc
handler with BrowserWindow.fromWebContents(event.sender) so the menu is resolved
from the IPC sender's webContents; update the IPC handler that receives the menu
payload (the ipcMain.handle callback) to validate/parse the incoming array into
the shared ContextMenuItem type using the existing IPC validation utility before
mapping to Menu.buildFromTemplate, and pass
BrowserWindow.fromWebContents(event.sender) into Menu.popup to avoid null or
wrong focused-window cases.

Comment on lines +87 to +112
const handleThreadContextMenu = useCallback(
async (threadId: string) => {
if (!api) return;
const clicked = await api.contextMenu.show([
{ id: "delete", label: "Delete" },
]);
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],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t delete the thread if stopping the session fails.
Errors are swallowed and deletion proceeds, which can orphan a running provider session with no UI to stop it. Consider surfacing the error (or at least aborting deletion) unless you can distinguish “already stopped.”

🛡️ Safer flow
       if (thread.session?.sessionId) {
         try {
           await api.providers.stopSession({
             sessionId: thread.session.sessionId,
           });
-        } catch {
-          // Session may already be stopped
+        } catch (error) {
+          // Consider surfacing error so we don't orphan a running session
+          return;
         }
       }
 
       dispatch({ type: "DELETE_THREAD", threadId });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleThreadContextMenu = useCallback(
async (threadId: string) => {
if (!api) return;
const clicked = await api.contextMenu.show([
{ id: "delete", label: "Delete" },
]);
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],
);
const handleThreadContextMenu = useCallback(
async (threadId: string) => {
if (!api) return;
const clicked = await api.contextMenu.show([
{ id: "delete", label: "Delete" },
]);
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 (error) {
// Consider surfacing error so we don't orphan a running session
return;
}
}
dispatch({ type: "DELETE_THREAD", threadId });
},
[api, dispatch, state.threads],
);
🤖 Prompt for AI Agents
In `@apps/renderer/src/components/Sidebar.tsx` around lines 87 - 112,
handleThreadContextMenu currently swallows errors from api.providers.stopSession
and proceeds to dispatch DELETE_THREAD, which can orphan an active session;
change the flow in handleThreadContextMenu so that if stopSession throws you do
not delete the thread unless you can detect a harmless "already stopped"
condition—catch the error from api.providers.stopSession, check the error
message/code for an “already stopped” sentinel (or otherwise surface the error
via UI/api.logger) and abort the dispatch({ type: "DELETE_THREAD", ... }) when
stopSession failed, only allowing deletion when stopSession succeeded or the
error explicitly indicates the session was already stopped; reference
handleThreadContextMenu, api.providers.stopSession, and the DELETE_THREAD
dispatch to locate and update the logic.

Comment thread apps/web/src/store.ts
Comment on lines +45 to +46
| { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }
| { type: "DELETE_THREAD"; threadId: string };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Active-thread fallback should stay within the same project.
When deleting the active thread, picking the first remaining thread globally can jump the user into an unrelated project. Prefer the next thread in the same project (or null if none).

🔧 Suggested adjustment
     case "DELETE_THREAD": {
+      const deleted = state.threads.find((t) => t.id === action.threadId);
       const threads = state.threads.filter((t) => t.id !== action.threadId);
-      const activeThreadId =
-        state.activeThreadId === action.threadId
-          ? (threads[0]?.id ?? null)
-          : state.activeThreadId;
+      const fallback =
+        deleted?.projectId
+          ? threads.find((t) => t.projectId === deleted.projectId) ?? null
+          : threads[0] ?? null;
+      const activeThreadId =
+        state.activeThreadId === action.threadId
+          ? (fallback?.id ?? null)
+          : state.activeThreadId;
       return { ...state, threads, activeThreadId };
     }

Also applies to: 332-338

🤖 Prompt for AI Agents
In `@apps/renderer/src/store.ts` around lines 45 - 46, The DELETE_THREAD action
currently falls back to the first remaining thread globally which can switch
projects; change the reducer logic that handles { type: "DELETE_THREAD";
threadId: string } so that if the deleted thread equals the current active
thread (activeThreadId) you find the next active thread from the same project
only (e.g., filter threads by projectId === deletedThread.projectId) and set
activeThreadId to that thread's id or null if none; apply the same same-project
fallback change to the other deletion-handling block referenced (the logic
duplicated around the other DELETE_THREAD handling at the second location).

]);
if (clicked !== "delete") return;

const thread = state.threads.find((t) => t.id === threadId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

components/Sidebar.tsx:171 The state.threads captured in the closure may become stale while awaiting api.contextMenu.show. Consider re-reading the thread from the current store state after the await returns, before stopping the session.

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/web/src/components/Sidebar.tsx around line 171:

The `state.threads` captured in the closure may become stale while awaiting `api.contextMenu.show`. Consider re-reading the thread from the current store state after the await returns, before stopping the session.

threadId: thread.id,
})
}
onContextMenu={(e) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

components/Sidebar.tsx:320 Consider only calling e.preventDefault() when api.contextMenu.show will actually display a menu. Currently in non-Electron web mode, the native context menu is blocked but nothing appears because desktopBridge is undefined.

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/web/src/components/Sidebar.tsx around line 320:

Consider only calling `e.preventDefault()` when `api.contextMenu.show` will actually display a menu. Currently in non-Electron web mode, the native context menu is blocked but nothing appears because `desktopBridge` is undefined.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 11, 2026

Greptile Overview

Greptile Summary

This PR adds a native, platform-consistent context menu on sidebar thread items to support deleting threads.

Changes span the IPC contract (packages/contracts/src/ipc.ts), Electron main process (apps/desktop/src/main.ts) and preload bridge (apps/desktop/src/preload.ts) to expose a nativeApi.contextMenu.show() method that returns the clicked menu item id (or null if dismissed). The renderer (apps/renderer/src/components/Sidebar.tsx) wires onContextMenu on each thread button to show the menu, optionally stop an active provider session for that thread, then dispatches a new DELETE_THREAD action handled in the store reducer (apps/renderer/src/store.ts) to remove the thread and adjust activeThreadId.

No merge-blocking issues were found in the reviewed changes; the IPC wiring and type contract are consistent end-to-end.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk.
  • Changes are small and localized, add a new IPC channel + preload bridge + renderer handler, and the new reducer action cleanly updates state. The contract between renderer/preload/main is consistent and there are no observable runtime/type-breaking issues introduced.
  • No files require special attention

Important Files Changed

Filename Overview
apps/desktop/src/main.ts Adds IPC handler to show a native context menu via Electron Menu and return clicked item id/null; change is self-contained and matches renderer/preload contract.
apps/desktop/src/preload.ts Adds nativeApi.contextMenu.show() that forwards menu item templates to main process via ipcRenderer.invoke; aligns with NativeApi typing.
apps/renderer/src/components/Sidebar.tsx Adds right-click handler on thread buttons to show native context menu and dispatch DELETE_THREAD after stopping any active provider session.
apps/renderer/src/store.ts Introduces DELETE_THREAD action in reducer to remove a thread and adjust activeThreadId when deleting the active thread.
packages/contracts/src/ipc.ts Extends IPC_CHANNELS and NativeApi contract with contextMenuShow channel and ContextMenuItem/contextMenu.show types.

Sequence Diagram

sequenceDiagram
    participant R as Renderer (Sidebar)
    participant P as Preload (contextBridge)
    participant M as Main Process (ipcMain)
    participant E as Electron Menu
    participant S as Store/Reducer

    R->>P: nativeApi.contextMenu.show([{id,label}])
    P->>M: ipcRenderer.invoke("context-menu:show", items)
    M->>E: Menu.buildFromTemplate(items)
    M->>E: menu.popup(window)
    alt User clicks item
        E-->>M: click handler (resolve(item.id))
        M-->>P: Promise resolves with id
        P-->>R: Promise resolves with id
        R->>P: nativeApi.providers.stopSession({sessionId}) (optional)
        P->>M: ipcRenderer.invoke("provider:session:stop", payload)
        M-->>P: resolves
        P-->>R: resolves
        R->>S: dispatch({type:"DELETE_THREAD", threadId})
        S-->>R: state updates (threads filtered, activeThreadId updated)
    else User dismisses menu
        E-->>M: popup callback (resolve(null))
        M-->>P: Promise resolves null
        P-->>R: Promise resolves null
    end
Loading

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/contracts/src/ipc.ts`:
- Around line 28-31: Remove the unused exported interface ContextMenuItem from
the file: delete the `export interface ContextMenuItem { id: string; label:
string }` declaration and any related export references so the contract aligns
with the inline generic used by `contextMenu.show` (which relies on `{ id: T;
label: string }[]` for proper inference); also run a quick grep for
`ContextMenuItem` and remove any leftover imports/usages elsewhere if found.

Comment thread packages/contracts/src/ipc.ts Outdated
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>
@juliusmarminge juliusmarminge force-pushed the juliusmarminge/sidebar-delete-thread branch from ab360e3 to eef2901 Compare February 11, 2026 21:36
@juliusmarminge juliusmarminge merged commit 169d06f into main Feb 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant