From b1791c8af8e813fbf8ebdb524a85880f356404a0 Mon Sep 17 00:00:00 2001 From: Hauke Schnau Date: Fri, 11 Sep 2026 09:39:14 +0200 Subject: [PATCH 1/4] fix(web): let browsers pick VSCodium, Cursor or Zed for remote open A browser cannot probe which editors the viewing machine has, so the remote open-in-editor picker hardcoded VS Code. On a machine with VSCodium or Cursor but no VS Code nothing handles vscode://, and Open silently does nothing. Offer every remote-capable editor in browsers, VS Code first, and remember the pick as the picker already does for local editors. The desktop app keeps probing its own machine. The preferred-editor fallback now takes the first entry of the list it is given; the server's PATH probe already returns catalog order, so local behavior is unchanged. --- apps/web/src/editorPreferences.ts | 13 +++++++++---- apps/web/src/remoteOpen.test.ts | 10 +++++++++- apps/web/src/remoteOpen.ts | 22 +++++++++++++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 8e7be4070215f..4aa96fa93dbbf 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,4 +1,4 @@ -import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts"; +import { EditorId, EnvironmentId } from "@t3tools/contracts"; import { mapAtomCommandResult, type AtomCommandFailure, @@ -38,12 +38,17 @@ export class PreferredEditorUnavailableError extends Schema.TaggedError) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); const effectiveEditor = useMemo(() => { if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor; - return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null; + return availableEditors[0] ?? null; }, [lastEditor, availableEditors]); return [effectiveEditor, setLastEditor] as const; @@ -55,9 +60,9 @@ export function resolveAndPersistPreferredEditor( const availableEditorIds = new Set(availableEditors); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); if (stored && availableEditorIds.has(stored)) return stored; - const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; + const editor = availableEditors[0] ?? null; if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); - return editor ?? null; + return editor; } export function useOpenInPreferredEditor( diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 0f636a526cef9..580f6a2bd2a4e 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -7,7 +7,7 @@ import { import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +import { BROWSER_REMOTE_EDITORS, resolveRemoteOpenState } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -118,6 +118,14 @@ describe("resolveRemoteOpenState", () => { }); }); +describe("BROWSER_REMOTE_EDITORS", () => { + it("leads with VS Code and offers the other remote-capable editors", () => { + expect(BROWSER_REMOTE_EDITORS[0]).toBe("vscode"); + expect(BROWSER_REMOTE_EDITORS).toEqual(expect.arrayContaining(["vscodium", "cursor", "zed"])); + expect(BROWSER_REMOTE_EDITORS).not.toContain("idea"); + }); +}); + describe("buildRemoteOpenUrl", () => { it("builds a vscode-remote deep link", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 7f23d408844ee..4777363f5e60d 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -124,24 +124,36 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO /** * Editors offered in remote-link mode. The desktop app probes the machine the - * renderer runs on; a browser cannot, so it offers VS Code only. + * renderer runs on. A browser cannot, so it offers every remote-capable + * editor and remembers the pick; VS Code leads as the default. + */ +export const BROWSER_REMOTE_EDITORS: ReadonlyArray = [ + "vscode", + ...REMOTE_CAPABLE_EDITOR_IDS.filter((id) => id !== "vscode"), +]; + +/** + * Desktop fallback when the probe fails, finds nothing, or is missing from an + * older desktop shell (the bridge contract documents VS Code only there). */ const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; let cachedProbedEditors: ReadonlyArray | null = null; export function useRemoteCapableEditors(): ReadonlyArray { + const bridge = window.desktopBridge; + const unprobedEditors = bridge === undefined ? BROWSER_REMOTE_EDITORS : REMOTE_FALLBACK_EDITORS; const [editors, setEditors] = useState>( - () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, + () => cachedProbedEditors ?? unprobedEditors, ); useEffect(() => { if (cachedProbedEditors !== null) { return; } - const probe = window.desktopBridge?.probeRemoteEditors; + const probe = bridge?.probeRemoteEditors; if (probe === undefined) { - cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + cachedProbedEditors = unprobedEditors; return; } let cancelled = false; @@ -160,7 +172,7 @@ export function useRemoteCapableEditors(): ReadonlyArray { return () => { cancelled = true; }; - }, []); + }, [bridge, unprobedEditors]); return editors; } From 8e6d72d927b8c3b20f9a0847fc8eafd9bef022fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:23:41 +0000 Subject: [PATCH 2/4] fix(web): verify browser remote editor choices against current clients --- .../chat/OpenInPicker.browser.test.tsx | 26 ++++++++++++++++--- apps/web/src/editorPreferences.test.ts | 16 ++++++++++++ apps/web/src/editorPreferences.ts | 12 +++------ apps/web/src/remoteOpen.test.ts | 10 +------ apps/web/src/remoteOpen.ts | 13 +++------- 5 files changed, 47 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 6f418a2bbab5e..8129b94f3ca10 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -5,7 +5,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { RemoteOpenState } from "../../remoteOpen"; +import { useRemoteCapableEditors, type RemoteOpenState } from "../../remoteOpen"; const state = vi.hoisted(() => ({ allowed: new Set(), @@ -60,7 +60,7 @@ vi.mock(import("../../editorPreferences"), async (importOriginal) => { vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ ...(await importOriginal()), useRemoteOpenState: () => state.remote, - useRemoteCapableEditors: () => state.clientEditors, + useRemoteCapableEditors: vi.fn(() => state.clientEditors), useRemoteOpenHint: () => [false, state.markHintSeen] as const, openRemoteEditorUrl: state.openUrl, })); @@ -77,6 +77,7 @@ beforeEach(() => { state.allowed.clear(); state.remote = { mode: "local-exec" }; state.clientEditors = editors; + vi.mocked(useRemoteCapableEditors).mockImplementation(() => state.clientEditors); state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.openUrl.mockReset().mockResolvedValue(true); state.setPreferred.mockReset(); @@ -227,7 +228,9 @@ it("reveals a file in the file manager instead of opening it", async () => { }); describe("client editor links", () => { - it("opens an SSH editor URL without host operate permission", async () => { + it("offers browser remote editors and remembers a choice without host operate permission", async () => { + const actual = await vi.importActual("../../remoteOpen"); + vi.mocked(useRemoteCapableEditors).mockImplementation(actual.useRemoteCapableEditors); state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; await renderPicker(); expect(primaryButton().disabled).toBe(false); @@ -238,6 +241,23 @@ describe("client editor links", () => { expect(state.run).not.toHaveBeenCalled(); expect(state.markHintSeen).toHaveBeenCalledOnce(); expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode"); + + await click(byLabel("Choose editor")); + const labels = menuItems().map((item) => item.textContent); + expect(labels).toEqual(expect.arrayContaining(["IntelliJ IDEA", "VSCodium", "Cursor", "Zed"])); + await click(menuItems().find((item) => item.textContent === "VSCodium")!); + expect(state.openUrl).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscodium"'); + + await act(async () => root.unmount()); + root = createRoot(container); + await renderPicker(); + await pressOpenFavorite(); + expect(state.openUrl).toHaveBeenNthCalledWith( + 2, + "vscodium://vscode-remote/ssh-remote+test-host/work/project", + ); + expect(state.run).not.toHaveBeenCalled(); }); it("does not change preferences when the client rejects the SSH URL", async () => { diff --git a/apps/web/src/editorPreferences.test.ts b/apps/web/src/editorPreferences.test.ts index 2c23af4b4ff95..de972b0ed1633 100644 --- a/apps/web/src/editorPreferences.test.ts +++ b/apps/web/src/editorPreferences.test.ts @@ -80,6 +80,22 @@ it("keeps the stored available editor for an authorized launch", async () => { expect(state.setPreference).not.toHaveBeenCalled(); }); +it("launches and remembers the first available editor when the saved choice is unavailable", async () => { + state.allowed.add(secondary); + state.getPreference.mockReturnValue("zed"); + const open = useOpenInPreferredEditor(secondary, ["vscode", "cursor"]); + expect(await open("/work")).toMatchObject({ _tag: "Success", value: "vscode" }); + expect(state.run).toHaveBeenCalledExactlyOnceWith({ + environmentId: secondary, + input: { cwd: "/work", editor: "vscode" }, + }); + expect(state.setPreference).toHaveBeenCalledExactlyOnceWith( + "t3code:last-editor", + "vscode", + expect.anything(), + ); +}); + it("returns the existing failures for a missing environment or editor", async () => { state.allowed.add(secondary); expect((await useOpenInPreferredEditor(null, ["vscode"])("/work"))._tag).toBe("Failure"); diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index e54e46125745e..3c4a8c0e6954f 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -43,24 +43,20 @@ export class PreferredEditorUnavailableError extends Schema.TaggedError) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); const effectiveEditor = - lastEditor && availableEditors.includes(lastEditor) ? lastEditor : (availableEditors[0] ?? null); + lastEditor && availableEditors.includes(lastEditor) + ? lastEditor + : (availableEditors[0] ?? null); return [effectiveEditor, setLastEditor] as const; } function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null { - const availableEditorIds = new Set(availableEditors); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); - if (stored && availableEditorIds.has(stored)) return stored; + if (stored && availableEditors.includes(stored)) return stored; const editor = availableEditors[0] ?? null; if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); return editor; diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ded14734b24ac..d64161c66917b 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -7,7 +7,7 @@ import { import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { BROWSER_REMOTE_EDITORS, resolveRemoteOpenState } from "./remoteOpen"; +import { resolveRemoteOpenState } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -118,14 +118,6 @@ describe("resolveRemoteOpenState", () => { }); }); -describe("BROWSER_REMOTE_EDITORS", () => { - it("leads with VS Code and offers the other remote-capable editors", () => { - expect(BROWSER_REMOTE_EDITORS[0]).toBe("vscode"); - expect(BROWSER_REMOTE_EDITORS).toEqual(expect.arrayContaining(["vscodium", "cursor", "zed"])); - expect(BROWSER_REMOTE_EDITORS).not.toContain("idea"); - }); -}); - describe("buildRemoteOpenUrl", () => { it("builds a vscode-remote deep link", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 42f39bec48563..337de4371cf9e 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -130,20 +130,13 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO return useRemoteOpenResolution(environmentId).state; } -/** - * Editors offered in remote-link mode. The desktop app probes the machine the - * renderer runs on. A browser cannot, so it offers every remote-capable - * editor and remembers the pick; VS Code leads as the default. - */ -export const BROWSER_REMOTE_EDITORS: ReadonlyArray = [ +// Browsers cannot probe installed editors; offer all remote choices with VS Code as the default. +const BROWSER_REMOTE_EDITORS: ReadonlyArray = [ "vscode", ...REMOTE_CAPABLE_EDITOR_IDS.filter((id) => id !== "vscode"), ]; -/** - * Desktop fallback when the probe fails, finds nothing, or is missing from an - * older desktop shell (the bridge contract documents VS Code only there). - */ +// Older desktop bridges without a probe support only the VS Code fallback. const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; let cachedProbedEditors: ReadonlyArray | null = null; From fb603d65c9a2de4cfb5718b546fde54a8362acd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:35:08 +0000 Subject: [PATCH 3/4] fix(web): route the chat editor shortcut through the client choice --- .../chat/OpenInPicker.browser.test.tsx | 98 +++++++++++++------ apps/web/src/components/chat/OpenInPicker.tsx | 7 +- .../components/chat/OpenInPickerShortcut.ts | 53 +++++++++- .../components/chat/ThreadDetailsPanel.tsx | 1 + 4 files changed, 123 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 8129b94f3ca10..48b26a7300434 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -66,6 +66,7 @@ vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ })); import { OpenInPicker } from "./OpenInPicker"; +import { useOpenFavoriteEditorShortcut } from "./OpenInPickerShortcut"; const primary = EnvironmentId.make("primary"); const selected = EnvironmentId.make("selected"); @@ -121,6 +122,19 @@ async function renderPicker( }); } +function ChatEditorShortcut({ panelMounted }: { panelMounted: boolean }) { + const props = { + environmentId: selected, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + availableEditors: editors, + openInCwd: "/work/project", + }; + useOpenFavoriteEditorShortcut({ ...props, enabled: true }); + return panelMounted ? ( + + ) : null; +} + function byLabel(label: string) { const element = document.querySelector(`[aria-label="${label}"]`); expect(element, label).not.toBeNull(); @@ -176,6 +190,24 @@ it.each(["local-exec", "remote-links"] as const)( ); describe("host editor access", () => { + it("rechecks the selected grant for the global shortcut while the panel is hidden", async () => { + state.allowed.add(selected); + await act(async () => root.render()); + state.allowed.delete(selected); + state.allowed.add(primary); + await pressOpenFavorite(); + expect.soft(state.run).not.toHaveBeenCalled(); + + state.run.mockClear(); + state.allowed.add(selected); + await pressOpenFavorite(); + expect(state.run).toHaveBeenCalledExactlyOnceWith({ + environmentId: selected, + input: { cwd: "/work/project", editor: "vscode" }, + }); + expect(state.openUrl).not.toHaveBeenCalled(); + }); + it("uses the selected grant for both the button and a retained shortcut", async () => { state.allowed.add(selected); await renderPicker(); @@ -228,37 +260,45 @@ it("reveals a file in the file manager instead of opening it", async () => { }); describe("client editor links", () => { - it("offers browser remote editors and remembers a choice without host operate permission", async () => { - const actual = await vi.importActual("../../remoteOpen"); - vi.mocked(useRemoteCapableEditors).mockImplementation(actual.useRemoteCapableEditors); - state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; - await renderPicker(); - expect(primaryButton().disabled).toBe(false); - await click(primaryButton()); - expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( - "vscode://vscode-remote/ssh-remote+test-host/work/project", - ); - expect(state.run).not.toHaveBeenCalled(); - expect(state.markHintSeen).toHaveBeenCalledOnce(); - expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode"); + it.each([true, false])( + "remembers a browser editor and opens it once through the chat shortcut (panel mounted: %s)", + async (panelMounted) => { + const actual = await vi.importActual("../../remoteOpen"); + vi.mocked(useRemoteCapableEditors).mockImplementation(actual.useRemoteCapableEditors); + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; + await renderPicker(); + expect(primaryButton().disabled).toBe(false); + await click(primaryButton()); + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+test-host/work/project", + ); + expect(state.run).not.toHaveBeenCalled(); + expect(state.markHintSeen).toHaveBeenCalledOnce(); + expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode"); - await click(byLabel("Choose editor")); - const labels = menuItems().map((item) => item.textContent); - expect(labels).toEqual(expect.arrayContaining(["IntelliJ IDEA", "VSCodium", "Cursor", "Zed"])); - await click(menuItems().find((item) => item.textContent === "VSCodium")!); - expect(state.openUrl).toHaveBeenCalledTimes(1); - expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscodium"'); + await click(byLabel("Choose editor")); + const labels = menuItems().map((item) => item.textContent); + expect(labels).toEqual( + expect.arrayContaining(["IntelliJ IDEA", "VSCodium", "Cursor", "Zed"]), + ); + await click(menuItems().find((item) => item.textContent === "VSCodium")!); + expect(state.openUrl).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscodium"'); - await act(async () => root.unmount()); - root = createRoot(container); - await renderPicker(); - await pressOpenFavorite(); - expect(state.openUrl).toHaveBeenNthCalledWith( - 2, - "vscodium://vscode-remote/ssh-remote+test-host/work/project", - ); - expect(state.run).not.toHaveBeenCalled(); - }); + await act(async () => root.unmount()); + root = createRoot(container); + state.allowed.add(selected); + state.openUrl.mockClear(); + await act(async () => root.render()); + await pressOpenFavorite(); + expect + .soft(state.openUrl) + .toHaveBeenCalledExactlyOnceWith( + "vscodium://vscode-remote/ssh-remote+test-host/work/project", + ); + expect.soft(state.run).not.toHaveBeenCalled(); + }, + ); it("does not change preferences when the client rejects the SSH URL", async () => { state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index c7fdf24f10e83..79aaad5735c5f 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -283,7 +283,7 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { if (!enableShortcut || !canOpenEditor) return; const handler = (e: globalThis.KeyboardEvent) => { - if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (e.defaultPrevented || !isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!openInCwd) return; if (!preferredEditor) return; if ( @@ -295,8 +295,9 @@ export const OpenInPicker = memo(function OpenInPicker({ e.preventDefault(); void openInEditor(preferredEditor); }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + // The file preview's shortcut takes precedence over the chat workspace shortcut. + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); }, [ canOpenEditor, enableShortcut, diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 91102b40c5096..392c0a69018dd 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -1,8 +1,21 @@ -import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + buildRemoteOpenUrl, + type EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { useEffect } from "react"; import { usePreferredEditor } from "../../editorPreferences"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; +import { + openRemoteEditorUrl, + useRemoteCapableEditors, + useRemoteOpenHint, + useRemoteOpenState, +} from "../../remoteOpen"; +import { readEnvironmentScope } from "../../state/session"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -20,13 +33,35 @@ export function useOpenFavoriteEditorShortcut({ openInCwd: string | null; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor] = usePreferredEditor(availableEditors); + const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + const [, markRemoteHintSeen] = useRemoteOpenHint(); + const [preferredEditor, setPreferredEditor] = usePreferredEditor( + remote.mode === "local-exec" ? availableEditors : remoteCapableEditors, + ); useEffect(() => { if (!enabled) return; const handler = (event: globalThis.KeyboardEvent) => { - if (!isOpenFavoriteEditorShortcut(event, keybindings)) return; + if (event.defaultPrevented || !isOpenFavoriteEditorShortcut(event, keybindings)) return; if (!openInCwd || !preferredEditor) return; + if (remote.mode === "remote-unavailable") return; + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + host: remote.host.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + event.preventDefault(); + void openRemoteEditorUrl(url).then((opened) => { + if (!opened) return; + markRemoteHintSeen(); + setPreferredEditor(preferredEditor); + }); + return; + } + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; event.preventDefault(); void openInEditorMutation({ @@ -39,5 +74,15 @@ export function useOpenFavoriteEditorShortcut({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [enabled, environmentId, keybindings, openInCwd, openInEditorMutation, preferredEditor]); + }, [ + enabled, + environmentId, + keybindings, + markRemoteHintSeen, + openInCwd, + openInEditorMutation, + preferredEditor, + remote, + setPreferredEditor, + ]); } diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 5bc2a4c4525b3..2a5eefd60d38d 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -127,6 +127,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { availableEditors={props.availableEditors} openInCwd={props.gitCwd} displayMode="panel" + enableShortcut={false} /> ) : null} From 2beebbbe25b445617cc67f9505f79ab1214a568b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:43:51 +0000 Subject: [PATCH 4/4] fix(web): let terminals consume editor shortcuts before file previews --- apps/web/src/components/chat/OpenInPicker.browser.test.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 48b26a7300434..968e26eac6d05 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -155,7 +155,7 @@ async function pressOpenFavorite() { bubbles: true, cancelable: true, }); - await act(async () => window.dispatchEvent(event)); + await act(async () => document.body.dispatchEvent(event)); return event; } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 79aaad5735c5f..f086960c77c61 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -296,8 +296,8 @@ export const OpenInPicker = memo(function OpenInPicker({ void openInEditor(preferredEditor); }; // The file preview's shortcut takes precedence over the chat workspace shortcut. - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); }, [ canOpenEditor, enableShortcut,