diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13ce641da1b3b..eac1ade473667 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -608,7 +608,10 @@ function useChatMarkdownState({ sshTarget: null, remoteOpenTargets: undefined, isDesktopRenderer: window.desktopBridge !== undefined, - desktopHomeEnvironmentId: readDesktopHomeEnvironmentId(), + desktopHomeEnvironmentId: + target._tag === "BearerConnectionTarget" + ? readDesktopHomeEnvironmentId(environmentId) + : null, }).mode !== "local-exec" ) { description = `The file was not found on ${target.label} at ${error.checkedPath ?? error.resource.path}.`; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76ad543630fb2..ad1e90b744eba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -233,7 +233,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ import { useEnvironmentQuery } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { - primaryServerAvailableEditorsAtom, + serverAvailableEditorsAtom, primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; @@ -1643,14 +1643,17 @@ export default function ChatView(props: ChatViewProps) { const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const availableEditors = useAtomValue( + serverAvailableEditorsAtom(activeThread?.environmentId ?? environmentId), + ); const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); - const showOpenInPicker = shouldShowOpenInPicker({ - activeProjectName: activeProject?.title, - activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId, - primaryEnvironmentId, - remoteOpenMode: remoteOpenState.mode, - }); + const showOpenInPicker = + Boolean(activeProject?.title) && + shouldShowOpenInPicker({ + environmentId: activeThread?.environmentId ?? environmentId, + primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, + }); useOpenFavoriteEditorShortcut({ enabled: showOpenInPicker && !backgroundThread, environmentId: activeThread?.environmentId ?? environmentId, diff --git a/apps/web/src/components/chat/OpenInPicker.logic.test.ts b/apps/web/src/components/chat/OpenInPicker.logic.test.ts new file mode 100644 index 0000000000000..bcf8629f8efae --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.logic.test.ts @@ -0,0 +1,24 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { shouldShowOpenInPicker } from "./OpenInPicker.logic"; + +const environmentId = EnvironmentId.make("service"); +const primaryEnvironmentId = EnvironmentId.make("desktop"); + +it.each([ + { primaryEnvironmentId: null, remoteOpenMode: "local-exec", expected: true }, + { primaryEnvironmentId: environmentId, remoteOpenMode: "local-exec", expected: true }, + { primaryEnvironmentId, remoteOpenMode: "local-exec", expected: false }, + { primaryEnvironmentId, remoteOpenMode: "remote-links", expected: true }, +] as const)( + "offers editor opening for $remoteOpenMode with primary $primaryEnvironmentId", + (input) => { + expect( + shouldShowOpenInPicker({ + ...input, + environmentId, + }), + ).toBe(input.expected); + }, +); diff --git a/apps/web/src/components/chat/OpenInPicker.logic.ts b/apps/web/src/components/chat/OpenInPicker.logic.ts index f5c9e17c392c5..30ca38b4070af 100644 --- a/apps/web/src/components/chat/OpenInPicker.logic.ts +++ b/apps/web/src/components/chat/OpenInPicker.logic.ts @@ -3,16 +3,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { RemoteOpenMode } from "../../remoteOpen"; export function shouldShowOpenInPicker(input: { - readonly activeProjectName: string | undefined; - readonly activeThreadEnvironmentId: EnvironmentId; + readonly environmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; readonly remoteOpenMode: RemoteOpenMode; }): boolean { - if (!input.activeProjectName) return false; - if ( - input.primaryEnvironmentId !== null && - input.activeThreadEnvironmentId === input.primaryEnvironmentId - ) { + if (input.primaryEnvironmentId === null) return true; + if (input.environmentId === input.primaryEnvironmentId) { return true; } return input.remoteOpenMode !== "local-exec"; diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index 49857b5db36a4..1bfaf77edc95d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -1,4 +1,9 @@ -import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + type EditorId, + type ProjectReadFileResult, +} from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../../test/render"; @@ -19,6 +24,10 @@ vi.mock(import("~/state/session"), async (original) => ({ readEnvironmentScope: () => true, useEnvironmentScope: () => true, })); +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + usePrimaryEnvironmentId: () => null, +})); vi.mock(import("~/state/filesystem"), async (original) => ({ ...(await original()), useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), @@ -95,7 +104,7 @@ function setup() { }; } -function markdownPreview(truncated: boolean) { +function markdownPreview(truncated: boolean, availableEditors: ReadonlyArray = []) { state.file = { relativePath: "tasks.md", contents: "- [ ] Keep the rest of this file\n", @@ -111,7 +120,7 @@ function markdownPreview(truncated: boolean) { threadRef={threadRef} composerDraftTarget={threadRef} keybindings={[]} - availableEditors={[]} + availableEditors={availableEditors} openInShortcutEnabled={false} revealLine={null} revealRequestId={0} @@ -125,6 +134,18 @@ function markdownPreview(truncated: boolean) { ); } +it("opens a local service file in the editor without a desktop primary", async () => { + using _settings = setup(); + await using screen = await render(markdownPreview(false, ["vscode"])); + const open = screen.getByRole("button", { name: "Open file in preferred editor" }); + await expect.element(open).toBeEnabled(); + await open.click(); + expect(state.writeFile).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { cwd: "/workspace/tasks.md", editor: "vscode" }, + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ed55da7c08b5c..2812fcdb56a03 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -53,6 +53,7 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { shouldShowOpenInPicker } from "~/components/chat/OpenInPicker.logic"; import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { MorphIcon } from "~/components/MorphIcon"; @@ -1283,7 +1284,11 @@ export default function FilePreviewPanel({ {absolutePath && - (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( + shouldShowOpenInPicker({ + environmentId, + primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, + }) ? ( { } }); }); + +it("recognizes a same-home service after a missing ID without reloading the page", async () => { + const target = new BearerConnectionTarget({ + environmentId, + label: "laptop service", + connectionId: "paired-1", + }); + const presentation: EnvironmentPresentation = { + entry: { target, profile: Option.none(), enabled: true }, + connection: { phase: "connecting", error: null, traceId: null }, + serverConfig: null, + }; + const presentationAtom = Atom.make(presentation); + using _presentation = vi + .spyOn(environmentPresentations, "presentationAtom") + .mockReturnValue(presentationAtom); + const getHomeEnvironmentId = vi.fn<() => Promise>().mockResolvedValue(null); + vi.stubGlobal("window", { desktopBridge: { getHomeEnvironmentId } }); + try { + expect(readDesktopHomeEnvironmentId(environmentId)).toBeNull(); + await vi.waitFor(() => expect(getHomeEnvironmentId).toHaveResolvedWith(null)); + + getHomeEnvironmentId.mockResolvedValue(environmentId); + appAtomRegistry.set(presentationAtom, { + ...presentation, + connection: { phase: "connected", error: null, traceId: null }, + }); + await vi.waitFor(() => { + expect(readDesktopHomeEnvironmentId(environmentId)).toBe(environmentId); + }); + + const callsAfterConnecting = getHomeEnvironmentId.mock.calls.length; + appAtomRegistry.set(presentationAtom, { ...presentation, serverConfig: null }); + expect(readDesktopHomeEnvironmentId(environmentId)).toBe(environmentId); + expect(getHomeEnvironmentId).toHaveBeenCalledTimes(callsAfterConnecting); + + appAtomRegistry.set(presentationAtom, { + ...presentation, + entry: { + ...presentation.entry, + target: new SshConnectionTarget({ environmentId, label: "remote", connectionId: "ssh-1" }), + }, + }); + expect(readDesktopHomeEnvironmentId(environmentId)).toBeNull(); + expect(getHomeEnvironmentId).toHaveBeenCalledTimes(callsAfterConnecting); + } finally { + appAtomRegistry.dispose(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 88c13ea965610..15266e2e3b3cb 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -102,20 +102,32 @@ export function resolveRemoteOpenState(input: { return REMOTE_UNAVAILABLE; } -// Read once per page: the id is permanent once a server has written it to the home. -const desktopHomeEnvironmentIdAtom = Atom.make((get): string | null => { - const read = window.desktopBridge?.getHomeEnvironmentId; - read?.().then( - (id) => { - if (id !== null) get.setSelf(id); - }, - () => undefined, - ); - return null; -}).pipe(Atom.keepAlive, Atom.withLabel("desktop-home-environment-id")); +const desktopHomeEnvironmentIdAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): string | null => { + const presentation = get(environmentPresentations.presentationAtom(environmentId)); + if (presentation?.entry.target._tag !== "BearerConnectionTarget") return null; + + const previous = Option.getOrNull(get.self()); + if (previous !== null) return previous; + + // Connection changes retry a missing file; a written home ID is permanent. + let active = true; + get.addFinalizer(() => { + active = false; + }); + const read = window.desktopBridge?.getHomeEnvironmentId; + read?.().then( + (id) => { + if (active && id !== null) get.setSelf(id); + }, + () => undefined, + ); + return null; + }).pipe(Atom.keepAlive, Atom.withLabel(`desktop-home-environment-id:${environmentId}`)), +); -export function readDesktopHomeEnvironmentId(): string | null { - return appAtomRegistry.get(desktopHomeEnvironmentIdAtom); +export function readDesktopHomeEnvironmentId(environmentId: EnvironmentId): string | null { + return appAtomRegistry.get(desktopHomeEnvironmentIdAtom(environmentId)); } function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState): boolean { @@ -145,7 +157,9 @@ const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) => remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets, isDesktopRenderer: window.desktopBridge !== undefined, desktopHomeEnvironmentId: - entry.target._tag === "BearerConnectionTarget" ? get(desktopHomeEnvironmentIdAtom) : null, + entry.target._tag === "BearerConnectionTarget" + ? get(desktopHomeEnvironmentIdAtom(environmentId)) + : null, }), isResolved: true, }; diff --git a/apps/web/src/state/server.test.ts b/apps/web/src/state/server.test.ts index 336f97c6ff8c2..d2edae13ed8c4 100644 --- a/apps/web/src/state/server.test.ts +++ b/apps/web/src/state/server.test.ts @@ -2,10 +2,14 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect import { EnvironmentId, type KeybindingRule, type ServerConfig } from "@t3tools/contracts"; import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveShortcutCommand } from "../keybindings"; -import { createShortcutKeybindingsAtom } from "./server"; +import { + createShortcutKeybindingsAtom, + serverAvailableEditorsAtom, + serverEnvironment, +} from "./server"; const PRIMARY = EnvironmentId.make("primary"); const A = EnvironmentId.make("a"); @@ -67,6 +71,24 @@ afterEach(() => { for (const registry of registries.splice(0)) registry.dispose(); }); +it("reads and refreshes the active environment's editors without a desktop primary", () => { + const aConfig = Atom.make(null); + const bConfig = Atom.make({ ...config([]), availableEditors: ["zed"] }); + using _configs = vi + .spyOn(serverEnvironment, "configValueAtom") + .mockImplementation((environmentId) => (environmentId === A ? aConfig : bConfig)); + const registry = AtomRegistry.make(); + registries.push(registry); + + expect(registry.get(serverAvailableEditorsAtom(A))).toEqual([]); + registry.set(aConfig, { ...config([]), availableEditors: ["vscode"] }); + expect(registry.get(serverAvailableEditorsAtom(A))).toEqual(["vscode"]); + expect(registry.get(serverAvailableEditorsAtom(B))).toEqual(["zed"]); + registry.set(aConfig, { ...config([]), availableEditors: ["cursor"] }); + expect(registry.get(serverAvailableEditorsAtom(A))).toEqual(["cursor"]); + expect(registry.get(serverAvailableEditorsAtom(B))).toEqual(["zed"]); +}); + describe("createShortcutKeybindingsAtom", () => { it("updates shortcuts from the first connected, configured environment without a primary", () => { const harness = makeHarness({}); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index bced4e959fa8e..d2beaf649cf32 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -166,10 +166,13 @@ export const primaryServerKeybindingsAtom = createShortcutKeybindingsAtom({ environmentsAtom: environmentConnectionsAtom, }).pipe(Atom.withLabel("web-primary-server-keybindings")); -export const primaryServerAvailableEditorsAtom = Atom.make( - (get): ReadonlyArray => - get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, -).pipe(Atom.withLabel("web-primary-server-available-editors")); +export const serverAvailableEditorsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(serverEnvironment.configValueAtom(environmentId))?.availableEditors ?? + EMPTY_AVAILABLE_EDITORS, + ).pipe(Atom.withLabel(`web-server-available-editors:${environmentId}`)), +); const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = [];