authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 13:15:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:49:04-08:00
log664451c2962dcc550e5bea54bb9c76536e3e6c94
tree0d096122b4d2f5dd0713973d7317317551bdf567
parentfd4ff8ca4648e11d945ccf2a8dea65f2037dcddc

Replace prompt dialog with inline rename in sidebar

Thread rename now uses an inline input directly in the sidebar row instead of opening a separate Electron BrowserWindow or browser window.prompt. Enter commits, Escape cancels, blur saves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

8 files changed, 575 insertions(+), 33 deletions(-)

apps/desktop/src/main.ts+116-16
......@@ -5,16 +5,18 @@ import net from "node:net";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron";
8import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
99import type { MenuItemConstructorOptions } from "electron";
1010
1111import { showDesktopConfirmDialog } from "./confirmDialog";
1212import { fixPath } from "./fixPath";
13import { showDesktopPromptDialog } from "./promptDialog";
1314
1415fixPath();
1516
1617const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1718const CONFIRM_CHANNEL = "desktop:confirm";
19const PROMPT_CHANNEL = "desktop:prompt";
1820const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1921const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
2022const MENU_ACTION_CHANNEL = "desktop:menu-action";
......@@ -36,6 +38,44 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null;
3638let isQuitting = false;
3739let desktopProtocolRegistered = false;
3840
41interface DesktopContextMenuItem {
42 id: string;
43 label: string;
44 destructive?: boolean;
45}
46
47let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
48
49function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
50 if (destructiveMenuIconCache !== undefined) {
51 return destructiveMenuIconCache ?? undefined;
52 }
53 try {
54 const icon =
55 process.platform === "darwin"
56 ? nativeImage.createFromNamedImage("trash").resize({
57 width: 14,
58 height: 14,
59 })
60 : nativeImage.createFromPath(resolveIconPath("png") ?? "").resize({
61 width: 14,
62 height: 14,
63 });
64 if (icon.isEmpty()) {
65 destructiveMenuIconCache = null;
66 return undefined;
67 }
68 if (process.platform === "darwin") {
69 icon.setTemplateImage(true);
70 }
71 destructiveMenuIconCache = icon;
72 return icon;
73 } catch {
74 destructiveMenuIconCache = null;
75 return undefined;
76 }
77}
78
3979protocol.registerSchemesAsPrivileged([
4080 {
4181 scheme: DESKTOP_SCHEME,
......@@ -396,24 +436,84 @@ function registerIpcHandlers(): void {
396436 return showDesktopConfirmDialog(message, owner);
397437 });
398438
399 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
400 ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => {
401 const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
402 if (!window) return null;
439 ipcMain.removeHandler(PROMPT_CHANNEL);
440 ipcMain.handle(PROMPT_CHANNEL, async (_event, message: unknown, defaultValue: unknown) => {
441 if (typeof message !== "string") {
442 return null;
443 }
403444
404 return new Promise<string | null>((resolve) => {
405 const menu = Menu.buildFromTemplate(
406 items.map((item) => ({
445 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
446 return showDesktopPromptDialog(
447 message,
448 typeof defaultValue === "string" ? defaultValue : "",
449 owner,
450 );
451 });
452
453 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
454 ipcMain.handle(
455 CONTEXT_MENU_CHANNEL,
456 async (
457 _event,
458 items: DesktopContextMenuItem[],
459 position?: { x: number; y: number },
460 ) => {
461 const normalizedItems = items
462 .filter((item) => typeof item.id === "string" && typeof item.label === "string")
463 .map((item) => ({
464 id: item.id,
407465 label: item.label,
408 click: () => resolve(item.id),
409 })),
410 );
411 menu.popup({
412 window,
413 callback: () => resolve(null),
466 destructive: item.destructive === true,
467 }));
468 if (normalizedItems.length === 0) {
469 return null;
470 }
471
472 const popupPosition =
473 position &&
474 Number.isFinite(position.x) &&
475 Number.isFinite(position.y) &&
476 position.x >= 0 &&
477 position.y >= 0
478 ? {
479 x: Math.floor(position.x),
480 y: Math.floor(position.y),
481 }
482 : null;
483
484 const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
485 if (!window) return null;
486
487 return new Promise<string | null>((resolve) => {
488 const template: MenuItemConstructorOptions[] = [];
489 let hasInsertedDestructiveSeparator = false;
490 for (const item of normalizedItems) {
491 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
492 template.push({ type: "separator" });
493 hasInsertedDestructiveSeparator = true;
494 }
495 const itemOption: MenuItemConstructorOptions = {
496 label: item.label,
497 click: () => resolve(item.id),
498 };
499 if (item.destructive) {
500 const destructiveIcon = getDestructiveMenuIcon();
501 if (destructiveIcon) {
502 itemOption.icon = destructiveIcon;
503 }
504 }
505 template.push(itemOption);
506 }
507
508 const menu = Menu.buildFromTemplate(template);
509 menu.popup({
510 window,
511 ...(popupPosition ?? {}),
512 callback: () => resolve(null),
513 });
414514 });
415 });
416 });
515 },
516 );
417517
418518 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
419519 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
apps/desktop/src/preload.ts+3-1
......@@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts";
33
44const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
55const CONFIRM_CHANNEL = "desktop:confirm";
6const PROMPT_CHANNEL = "desktop:prompt";
67const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
78const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
89const MENU_ACTION_CHANNEL = "desktop:menu-action";
......@@ -12,7 +13,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
1213 getWsUrl: () => wsUrl,
1314 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
1415 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
15 showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items),
16 prompt: (message, defaultValue) => ipcRenderer.invoke(PROMPT_CHANNEL, message, defaultValue),
17 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
1618 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
1719 onMenuAction: (listener) => {
1820 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
apps/desktop/src/promptDialog.ts created+231
......@@ -0,0 +1,231 @@
1import { randomBytes } from "node:crypto";
2import { BrowserWindow, ipcMain } from "electron";
3
4const PROMPT_WIDTH = 420;
5const PROMPT_HEIGHT = 188;
6
7function createPromptHtml(input: {
8 message: string;
9 defaultValue: string;
10 submitChannel: string;
11 cancelChannel: string;
12}): string {
13 const scriptConfig = JSON.stringify(input);
14 return `<!doctype html>
15<html lang="en">
16 <head>
17 <meta charset="UTF-8" />
18 <meta name="viewport" content="width=device-width,initial-scale=1.0" />
19 <title>${input.message}</title>
20 <style>
21 :root {
22 color-scheme: light dark;
23 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24 --bg: #fff;
25 --fg: #1a1a1a;
26 --muted: #6b6b6b;
27 --border: #d4d4d4;
28 --input-bg: #fafafa;
29 --focus-ring: rgba(59, 130, 246, 0.5);
30 --btn-bg: #f5f5f5;
31 --btn-hover: #e8e8e8;
32 --btn-primary-bg: #171717;
33 --btn-primary-fg: #fff;
34 --btn-primary-hover: #333;
35 }
36 @media (prefers-color-scheme: dark) {
37 :root {
38 --bg: #1e1e1e;
39 --fg: #e4e4e4;
40 --muted: #999;
41 --border: #3a3a3a;
42 --input-bg: #2a2a2a;
43 --focus-ring: rgba(96, 165, 250, 0.5);
44 --btn-bg: #333;
45 --btn-hover: #404040;
46 --btn-primary-bg: #e4e4e4;
47 --btn-primary-fg: #1a1a1a;
48 --btn-primary-hover: #d0d0d0;
49 }
50 }
51 * { box-sizing: border-box; margin: 0; }
52 body {
53 padding: 16px;
54 background: var(--bg);
55 color: var(--fg);
56 -webkit-font-smoothing: antialiased;
57 }
58 p {
59 margin-bottom: 10px;
60 font-size: 13px;
61 font-weight: 500;
62 }
63 input {
64 width: 100%;
65 padding: 7px 10px;
66 font-size: 13px;
67 font-family: inherit;
68 color: var(--fg);
69 background: var(--input-bg);
70 border: 1px solid var(--border);
71 border-radius: 6px;
72 outline: none;
73 transition: border-color 0.15s, box-shadow 0.15s;
74 }
75 input:focus {
76 border-color: var(--focus-ring);
77 box-shadow: 0 0 0 2px var(--focus-ring);
78 }
79 .buttons {
80 display: flex;
81 justify-content: flex-end;
82 gap: 8px;
83 margin-top: 14px;
84 }
85 button {
86 min-width: 72px;
87 padding: 6px 14px;
88 font-size: 12px;
89 font-family: inherit;
90 font-weight: 500;
91 border: 1px solid var(--border);
92 border-radius: 6px;
93 cursor: pointer;
94 background: var(--btn-bg);
95 color: var(--fg);
96 transition: background 0.15s;
97 }
98 button:hover { background: var(--btn-hover); }
99 button:active { opacity: 0.85; }
100 button[type="submit"] {
101 background: var(--btn-primary-bg);
102 color: var(--btn-primary-fg);
103 border-color: var(--btn-primary-bg);
104 }
105 button[type="submit"]:hover { background: var(--btn-primary-hover); }
106 </style>
107 </head>
108 <body>
109 <p id="message"></p>
110 <form id="form">
111 <input id="value" autocomplete="off" />
112 <div class="buttons">
113 <button id="cancel" type="button">Cancel</button>
114 <button type="submit">Save</button>
115 </div>
116 </form>
117 <script>
118 const { ipcRenderer } = require("electron");
119 const config = ${scriptConfig};
120 const message = document.getElementById("message");
121 const form = document.getElementById("form");
122 const valueInput = document.getElementById("value");
123 const cancelButton = document.getElementById("cancel");
124
125 message.textContent = config.message;
126 valueInput.value = config.defaultValue;
127
128 function cancel() {
129 ipcRenderer.send(config.cancelChannel);
130 window.close();
131 }
132
133 form.addEventListener("submit", (event) => {
134 event.preventDefault();
135 ipcRenderer.send(config.submitChannel, valueInput.value);
136 window.close();
137 });
138 cancelButton.addEventListener("click", cancel);
139 window.addEventListener("keydown", (event) => {
140 if (event.key === "Escape") {
141 event.preventDefault();
142 cancel();
143 }
144 });
145 window.addEventListener("DOMContentLoaded", () => {
146 valueInput.focus();
147 valueInput.select();
148 });
149 </script>
150 </body>
151</html>`;
152}
153
154export async function showDesktopPromptDialog(
155 message: string,
156 defaultValue: string,
157 ownerWindow: BrowserWindow | null,
158): Promise<string | null> {
159 const normalizedMessage = message.trim();
160 if (normalizedMessage.length === 0) {
161 return null;
162 }
163
164 const token = randomBytes(12).toString("hex");
165 const submitChannel = `desktop:prompt-dialog:submit:${token}`;
166 const cancelChannel = `desktop:prompt-dialog:cancel:${token}`;
167
168 const promptWindow = new BrowserWindow({
169 width: PROMPT_WIDTH,
170 height: PROMPT_HEIGHT,
171 useContentSize: true,
172 show: false,
173 resizable: false,
174 minimizable: false,
175 maximizable: false,
176 fullscreenable: false,
177 autoHideMenuBar: true,
178 title: normalizedMessage,
179 ...(ownerWindow ? { parent: ownerWindow } : {}),
180 modal: ownerWindow !== null,
181 webPreferences: {
182 nodeIntegration: true,
183 contextIsolation: false,
184 },
185 });
186 promptWindow.removeMenu();
187
188 return new Promise<string | null>((resolve) => {
189 let settled = false;
190
191 const settle = (value: string | null) => {
192 if (settled) return;
193 settled = true;
194 cleanup();
195 resolve(value);
196 };
197
198 const cleanup = () => {
199 ipcMain.removeAllListeners(submitChannel);
200 ipcMain.removeAllListeners(cancelChannel);
201 if (!promptWindow.isDestroyed()) {
202 promptWindow.destroy();
203 }
204 };
205
206 ipcMain.once(submitChannel, (_event, value: unknown) => {
207 if (typeof value !== "string") {
208 settle(null);
209 return;
210 }
211 settle(value);
212 });
213 ipcMain.once(cancelChannel, () => settle(null));
214 promptWindow.once("closed", () => settle(null));
215
216 const html = createPromptHtml({
217 message: normalizedMessage,
218 defaultValue,
219 submitChannel,
220 cancelChannel,
221 });
222 void promptWindow
223 .loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
224 .then(() => {
225 if (!promptWindow.isDestroyed()) {
226 promptWindow.show();
227 }
228 })
229 .catch(() => settle(null));
230 });
231}
apps/web/src/components/Sidebar.tsx+97-9
......@@ -1,5 +1,5 @@
11import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
2import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33import {
44 DEFAULT_MODEL,
55 ProjectId,
......@@ -266,6 +266,10 @@ export default function Sidebar() {
266266 const [newCwd, setNewCwd] = useState("");
267267 const [isPickingFolder, setIsPickingFolder] = useState(false);
268268 const [isAddingProject, setIsAddingProject] = useState(false);
269 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
270 const [renamingTitle, setRenamingTitle] = useState("");
271 const renamingCommittedRef = useRef(false);
272 const renamingInputRef = useRef<HTMLInputElement | null>(null);
269273 const pendingApprovalByThreadId = useMemo(() => {
270274 const map = new Map<ThreadId, boolean>();
271275 for (const thread of threads) {
......@@ -503,25 +507,74 @@ export default function Sidebar() {
503507 setIsPickingFolder(false);
504508 };
505509
510 const cancelRename = useCallback(() => {
511 setRenamingThreadId(null);
512 renamingInputRef.current = null;
513 }, []);
514
515 const commitRename = useCallback(
516 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
517 const trimmed = newTitle.trim();
518 if (trimmed.length === 0) {
519 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
520 cancelRename();
521 return;
522 }
523 if (trimmed === originalTitle) {
524 cancelRename();
525 return;
526 }
527 const api = readNativeApi();
528 if (!api) {
529 cancelRename();
530 return;
531 }
532 try {
533 await api.orchestration.dispatchCommand({
534 type: "thread.meta.update",
535 commandId: newCommandId(),
536 threadId,
537 title: trimmed,
538 });
539 } catch (error) {
540 toastManager.add({
541 type: "error",
542 title: "Failed to rename thread",
543 description: error instanceof Error ? error.message : "An error occurred.",
544 });
545 }
546 cancelRename();
547 },
548 [cancelRename],
549 );
550
506551 const handleThreadContextMenu = useCallback(
507552 async (threadId: ThreadId, position: { x: number; y: number }) => {
508553 const api = readNativeApi();
509554 if (!api) return;
510555 const clicked = await api.contextMenu.show(
511556 [
557 { id: "rename", label: "Rename thread" },
512558 { id: "mark-unread", label: "Mark unread" },
513 { id: "delete", label: "Delete" },
559 { id: "delete", label: "Delete", destructive: true },
514560 ],
515561 position,
516562 );
563 const thread = threads.find((t) => t.id === threadId);
564 if (!thread) return;
565
566 if (clicked === "rename") {
567 setRenamingThreadId(threadId);
568 setRenamingTitle(thread.title);
569 renamingCommittedRef.current = false;
570 return;
571 }
572
517573 if (clicked === "mark-unread") {
518574 markThreadUnread(threadId);
519575 return;
520576 }
521577 if (clicked !== "delete") return;
522
523 const thread = threads.find((t) => t.id === threadId);
524 if (!thread) return;
525578 if (appSettings.confirmThreadDelete) {
526579 const confirmed = await api.dialogs.confirm(
527580 [
......@@ -635,7 +688,10 @@ export default function Sidebar() {
635688 async (projectId: ProjectId, position: { x: number; y: number }) => {
636689 const api = readNativeApi();
637690 if (!api) return;
638 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
691 const clicked = await api.contextMenu.show(
692 [{ id: "delete", label: "Delete", destructive: true }],
693 position,
694 );
639695 if (clicked !== "delete") return;
640696
641697 const project = projects.find((entry) => entry.id === projectId);
......@@ -901,9 +957,41 @@ export default function Sidebar() {
901957 <span className="hidden md:inline">{threadStatus.label}</span>
902958 </span>
903959 )}
904 <span className="min-w-0 flex-1 truncate text-xs">
905 {thread.title}
906 </span>
960 {renamingThreadId === thread.id ? (
961 <input
962 ref={(el) => {
963 if (el && renamingInputRef.current !== el) {
964 renamingInputRef.current = el;
965 el.focus();
966 el.select();
967 }
968 }}
969 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
970 value={renamingTitle}
971 onChange={(e) => setRenamingTitle(e.target.value)}
972 onKeyDown={(e) => {
973 if (e.key === "Enter") {
974 e.preventDefault();
975 renamingCommittedRef.current = true;
976 void commitRename(thread.id, renamingTitle, thread.title);
977 } else if (e.key === "Escape") {
978 e.preventDefault();
979 renamingCommittedRef.current = true;
980 cancelRename();
981 }
982 }}
983 onBlur={() => {
984 if (!renamingCommittedRef.current) {
985 void commitRename(thread.id, renamingTitle, thread.title);
986 }
987 }}
988 onClick={(e) => e.stopPropagation()}
989 />
990 ) : (
991 <span className="min-w-0 flex-1 truncate text-xs">
992 {thread.title}
993 </span>
994 )}
907995 </div>
908996 <div className="ml-auto flex shrink-0 items-center gap-1.5">
909997 {terminalStatus && (
apps/web/src/contextMenuFallback.ts+3-3
......@@ -4,7 +4,7 @@
44 * with the clicked item id, or null if dismissed.
55 */
66export function showContextMenuFallback<T extends string>(
7 items: readonly { id: T; label: string }[],
7 items: readonly { id: T; label: string; destructive?: boolean }[],
88 position?: { x: number; y: number },
99): Promise<T | null> {
1010 return new Promise<T | null>((resolve) => {
......@@ -41,8 +41,8 @@ export function showContextMenuFallback<T extends string>(
4141 const btn = document.createElement("button");
4242 btn.type = "button";
4343 btn.textContent = item.label;
44 const isDeleteAction = item.id === "delete";
45 btn.className = isDeleteAction
44 const isDestructiveAction = item.destructive === true || item.id === "delete";
45 btn.className = isDestructiveAction
4646 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
4747 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
4848 btn.addEventListener("click", () => cleanup(item.id));
apps/web/src/wsNativeApi.test.ts+103
......@@ -9,6 +9,12 @@ import {
99import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1010
1111const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
12const showContextMenuFallbackMock = vi.fn<
13 <T extends string>(
14 items: readonly { id: T; label: string; destructive?: boolean }[],
15 position?: { x: number; y: number },
16 ) => Promise<T | null>
17>();
1218const channelListeners = new Map<string, Set<(data: unknown) => void>>();
1319const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(
1420 (channel, listener) => {
......@@ -33,6 +39,10 @@ vi.mock("./wsTransport", () => {
3339 };
3440});
3541
42vi.mock("./contextMenuFallback", () => ({
43 showContextMenuFallback: showContextMenuFallbackMock,
44}));
45
3646function emitPush(channel: string, data: unknown): void {
3747 const listeners = channelListeners.get(channel);
3848 if (!listeners) return;
......@@ -41,11 +51,24 @@ function emitPush(channel: string, data: unknown): void {
4151 }
4252}
4353
54function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } {
55 const testGlobal = globalThis as typeof globalThis & {
56 window?: Window & typeof globalThis & { desktopBridge?: unknown };
57 };
58 if (!testGlobal.window) {
59 testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown };
60 }
61 return testGlobal.window;
62}
63
4464beforeEach(() => {
4565 vi.resetModules();
4666 requestMock.mockReset();
67 showContextMenuFallbackMock.mockReset();
4768 subscribeMock.mockClear();
4869 channelListeners.clear();
70 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
71 Reflect.deleteProperty(getWindowForTest(), "prompt");
4972});
5073
5174afterEach(() => {
......@@ -307,4 +330,84 @@ describe("wsNativeApi", () => {
307330 toTurnCount: 1,
308331 });
309332 });
333
334 it("forwards context menu metadata to desktop bridge", async () => {
335 const showContextMenu = vi.fn().mockResolvedValue("delete");
336 Object.defineProperty(getWindowForTest(), "desktopBridge", {
337 configurable: true,
338 writable: true,
339 value: {
340 showContextMenu,
341 },
342 });
343
344 const { createWsNativeApi } = await import("./wsNativeApi");
345 const api = createWsNativeApi();
346 await api.contextMenu.show(
347 [
348 { id: "rename", label: "Rename thread" },
349 { id: "delete", label: "Delete", destructive: true },
350 ],
351 { x: 200, y: 300 },
352 );
353
354 expect(showContextMenu).toHaveBeenCalledWith(
355 [
356 { id: "rename", label: "Rename thread" },
357 { id: "delete", label: "Delete", destructive: true },
358 ],
359 { x: 200, y: 300 },
360 );
361 });
362
363 it("uses fallback context menu when desktop bridge is unavailable", async () => {
364 showContextMenuFallbackMock.mockResolvedValue("delete");
365 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
366
367 const { createWsNativeApi } = await import("./wsNativeApi");
368 const api = createWsNativeApi();
369 await api.contextMenu.show(
370 [{ id: "delete", label: "Delete", destructive: true }],
371 { x: 20, y: 30 },
372 );
373
374 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
375 [{ id: "delete", label: "Delete", destructive: true }],
376 { x: 20, y: 30 },
377 );
378 });
379
380 it("forwards prompt calls to desktop bridge", async () => {
381 const prompt = vi.fn().mockResolvedValue("renamed");
382 Object.defineProperty(getWindowForTest(), "desktopBridge", {
383 configurable: true,
384 writable: true,
385 value: {
386 prompt,
387 },
388 });
389
390 const { createWsNativeApi } = await import("./wsNativeApi");
391 const api = createWsNativeApi();
392 const value = await api.dialogs.prompt("Rename thread", "Old title");
393
394 expect(value).toBe("renamed");
395 expect(prompt).toHaveBeenCalledWith("Rename thread", "Old title");
396 });
397
398 it("uses browser prompt fallback when desktop bridge is unavailable", async () => {
399 const prompt = vi.fn().mockReturnValue("renamed");
400 Object.defineProperty(getWindowForTest(), "prompt", {
401 configurable: true,
402 writable: true,
403 value: prompt,
404 });
405
406 const { createWsNativeApi } = await import("./wsNativeApi");
407 const api = createWsNativeApi();
408 const value = await api.dialogs.prompt("Rename thread", "Old title");
409
410 expect(value).toBe("renamed");
411 expect(prompt).toHaveBeenCalledWith("Rename thread", "Old title");
412 });
310413});
apps/web/src/wsNativeApi.ts+12-2
......@@ -124,6 +124,16 @@ export function createWsNativeApi(): NativeApi {
124124 }
125125 return window.confirm(message);
126126 },
127 prompt: async (message, defaultValue) => {
128 if (window.desktopBridge) {
129 return window.desktopBridge.prompt(message, defaultValue);
130 }
131 try {
132 return window.prompt(message, defaultValue ?? "");
133 } catch {
134 return null;
135 }
136 },
127137 },
128138 terminal: {
129139 open: (input) => transport.request(WS_METHODS.terminalOpen, input),
......@@ -171,11 +181,11 @@ export function createWsNativeApi(): NativeApi {
171181 },
172182 contextMenu: {
173183 show: async <T extends string>(
174 items: readonly { id: T; label: string }[],
184 items: readonly { id: T; label: string; destructive?: boolean }[],
175185 position?: { x: number; y: number },
176186 ): Promise<T | null> => {
177187 if (window.desktopBridge) {
178 return window.desktopBridge.showContextMenu(items) as Promise<T | null>;
188 return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>;
179189 }
180190 return showContextMenuFallback(items, position);
181191 },
packages/contracts/src/ipc.ts+10-2
......@@ -37,12 +37,19 @@ import type {
3737} from "./orchestration";
3838import { EditorId } from "./editor";
3939
40export interface ContextMenuItem<T extends string = string> {
41 id: T;
42 label: string;
43 destructive?: boolean;
44}
45
4046export interface DesktopBridge {
4147 getWsUrl: () => string | null;
4248 pickFolder: () => Promise<string | null>;
4349 confirm: (message: string) => Promise<boolean>;
50 prompt: (message: string, defaultValue?: string) => Promise<string | null>;
4451 showContextMenu: <T extends string>(
45 items: readonly { id: T; label: string }[],
52 items: readonly ContextMenuItem<T>[],
4653 position?: { x: number; y: number },
4754 ) => Promise<T | null>;
4855 openExternal: (url: string) => Promise<boolean>;
......@@ -53,6 +60,7 @@ export interface NativeApi {
5360 dialogs: {
5461 pickFolder: () => Promise<string | null>;
5562 confirm: (message: string) => Promise<boolean>;
63 prompt: (message: string, defaultValue?: string) => Promise<string | null>;
5664 };
5765 terminal: {
5866 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
......@@ -85,7 +93,7 @@ export interface NativeApi {
8593 };
8694 contextMenu: {
8795 show: <T extends string>(
88 items: readonly { id: T; label: string }[],
96 items: readonly ContextMenuItem<T>[],
8997 position?: { x: number; y: number },
9098 ) => Promise<T | null>;
9199 };