diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index db26c525c0015..599a041ccaa71 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, installRelayTelemetryEnabled, + getHomeEnvironmentId, getSystemLocale, getPreferredSystemLanguages, getWindowFullscreenState, @@ -154,6 +155,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pasteAsText); yield* ipc.handle(revealWindow); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(getHomeEnvironmentId); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6a149e24ae695..93d81b48db59b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -34,6 +34,7 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_HOME_ENVIRONMENT_ID_CHANNEL = "desktop:get-home-environment-id"; export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 2ddc672891755..e0b247e62eba1 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -20,6 +20,7 @@ vi.mock("electron", () => ({ BrowserWindow: { fromWebContents: ownerWindow }, })); +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; @@ -33,6 +34,7 @@ import { getLocalEnvironmentBootstraps, installRelayTelemetryEnabled, pickProjectFavicon, + getHomeEnvironmentId, probeRemoteEditors, readMedia, } from "./window.ts"; @@ -386,3 +388,20 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("reads the home's environment id and treats an unwritten home as none", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-home-environment-" }); + const read = getHomeEnvironmentId.handler(undefined).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, { + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ); + assert.isNull(yield* read); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "environment-1\n"); + assert.equal(yield* read, "environment-1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9580b29b94bba..b9a54aa04c124 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -390,6 +390,22 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({ }), }); +export const getHomeEnvironmentId = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_HOME_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.getHomeEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe( + Effect.map((contents) => contents.trim() || null), + Effect.orElseSucceed(() => null), + ); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c44ec5133785..7d6475039c91f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -216,6 +216,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + getHomeEnvironmentId: () => + ipcRenderer.invoke(IpcChannels.GET_HOME_ENVIRONMENT_ID_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5e3c7ca96fd3b..eac1ade473667 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -150,7 +150,11 @@ import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; -import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; +import { + readDesktopHomeEnvironmentId, + resolveRemoteOpenState, + useRemoteOpenResolution, +} from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -604,6 +608,10 @@ function useChatMarkdownState({ sshTarget: null, remoteOpenTargets: undefined, isDesktopRenderer: window.desktopBridge !== undefined, + 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.sameHome.browser.test.tsx b/apps/web/src/components/ChatView.sameHome.browser.test.tsx new file mode 100644 index 0000000000000..7baf604f82f24 --- /dev/null +++ b/apps/web/src/components/ChatView.sameHome.browser.test.tsx @@ -0,0 +1,97 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +import "~/index.css"; +// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) +// takes 6-11s; importing it here keeps that out of the test's timeout. +import "~/components/ChatView"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + PROJECT_ID, + installFakeEnvironment, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { render } from "../../test/render"; + +const pickerState = vi.hoisted(() => ({ sameHome: false })); +vi.mock(import("~/state/environments"), async (original) => { + const actual = await original(); + return { + ...actual, + usePrimaryEnvironment: () => { + const primary = actual.usePrimaryEnvironment(); + return pickerState.sameHome && primary !== null + ? { ...primary, environmentId: EnvironmentId.make("wsl-primary") } + : primary; + }, + }; +}); +vi.mock(import("~/remoteOpen"), async (original) => { + const actual = await original(); + return { + ...actual, + useRemoteOpenResolution: (environmentId) => { + const resolution = actual.useRemoteOpenResolution(environmentId); + return pickerState.sameHome + ? { state: { mode: "local-exec" as const }, isResolved: true, isSameHomeBearer: true } + : resolution; + }, + }; +}); + +// The transport uses deterministic server fixtures. +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it("keeps workspace editor controls and its shortcut target for a same-home service with another primary", async () => { + await page.viewport(1400, 900); + pickerState.sameHome = true; + using _reset = { + [Symbol.dispose]() { + pickerState.sameHome = false; + }, + }; + using _environment = installFakeEnvironment([]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-secondary-home"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { + threadId: ThreadId.make("thread-secondary-home"), + }); + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); + const toggle = page.getByRole("button", { name: "Toggle thread details panel", exact: true }); + if (toggle.element().getAttribute("aria-pressed") === "false") await toggle.click(); + await expect.element(page.getByRole("group", { name: "Open in editor" })).toBeVisible(); + expect(document.querySelector("[data-open-favorite-editor]")).not.toBeNull(); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff4a642b5c31d..df6c7a5f41646 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"; @@ -281,7 +281,7 @@ import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useSideChat } from "./chat/useSideChat"; -import { useRemoteOpenState } from "~/remoteOpen"; +import { useRemoteOpenResolution } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { @@ -1643,14 +1643,20 @@ export default function ChatView(props: ChatViewProps) { const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); - const showOpenInPicker = shouldShowOpenInPicker({ - activeProjectName: activeProject?.title, - activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId, - primaryEnvironmentId, - remoteOpenMode: remoteOpenState.mode, - }); + const availableEditors = useAtomValue( + serverAvailableEditorsAtom(activeThread?.environmentId ?? environmentId), + ); + const remoteOpenResolution = useRemoteOpenResolution( + activeThread?.environmentId ?? environmentId, + ); + const showOpenInPicker = + Boolean(activeProject?.title) && + shouldShowOpenInPicker({ + environmentId: activeThread?.environmentId ?? environmentId, + primaryEnvironmentId, + remoteOpenMode: remoteOpenResolution.state.mode, + isSameHomeBearer: remoteOpenResolution.isSameHomeBearer, + }); 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..5349b93a8e7dd --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.logic.test.ts @@ -0,0 +1,31 @@ +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: "local-exec", + isSameHomeBearer: true, + expected: true, + }, + { primaryEnvironmentId, remoteOpenMode: "remote-links", expected: true }, +] as const)( + "offers editor opening for $remoteOpenMode with primary $primaryEnvironmentId", + (input) => { + expect( + shouldShowOpenInPicker({ + isSameHomeBearer: false, + ...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..54906ba55dc87 100644 --- a/apps/web/src/components/chat/OpenInPicker.logic.ts +++ b/apps/web/src/components/chat/OpenInPicker.logic.ts @@ -3,17 +3,15 @@ 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; + readonly isSameHomeBearer: boolean; }): 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; } + if (input.remoteOpenMode === "local-exec" && input.isSameHomeBearer) 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..9dcdb0502ea25 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"; @@ -7,6 +12,8 @@ const state = vi.hoisted(() => ({ file: null as ProjectReadFileResult | null, optimistic: null as ProjectReadFileResult | null, writeFile: vi.fn(), + primaryEnvironmentId: null as EnvironmentId | null, + isSameHomeBearer: false, })); vi.mock(import("@t3tools/client-runtime/state/runtime"), async (original) => ({ @@ -19,6 +26,10 @@ vi.mock(import("~/state/session"), async (original) => ({ readEnvironmentScope: () => true, useEnvironmentScope: () => true, })); +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + usePrimaryEnvironmentId: () => state.primaryEnvironmentId, +})); vi.mock(import("~/state/filesystem"), async (original) => ({ ...(await original()), useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), @@ -39,7 +50,12 @@ vi.mock(import("~/state/server"), async (original) => { vi.mock(import("~/remoteOpen"), async (original) => ({ ...(await original()), useRemoteOpenState: () => ({ mode: "local-exec" }) as never, - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, + useRemoteOpenResolution: () => + ({ + state: { mode: "local-exec" }, + isResolved: true, + isSameHomeBearer: state.isSameHomeBearer, + }) as never, })); vi.mock(import("~/hooks/useTheme"), async (original) => ({ ...(await original()), @@ -83,6 +99,8 @@ function setup() { localStorage.setItem(keys[0]!, "false"); localStorage.setItem(keys[1]!, "true"); state.optimistic = null; + state.primaryEnvironmentId = null; + state.isSameHomeBearer = false; state.writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); return { [Symbol.dispose]() { @@ -95,7 +113,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 +129,7 @@ function markdownPreview(truncated: boolean) { threadRef={threadRef} composerDraftTarget={threadRef} keybindings={[]} - availableEditors={[]} + availableEditors={availableEditors} openInShortcutEnabled={false} revealLine={null} revealRequestId={0} @@ -125,6 +143,23 @@ function markdownPreview(truncated: boolean) { ); } +it.each([null, EnvironmentId.make("wsl-primary")])( + "opens a same-home service file with primary %s", + async (primaryEnvironmentId) => { + using _settings = setup(); + state.primaryEnvironmentId = primaryEnvironmentId; + state.isSameHomeBearer = true; + 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..3322dbba439c9 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -53,10 +53,11 @@ 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"; -import { useRemoteOpenState } from "~/remoteOpen"; +import { useRemoteOpenResolution } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1027,7 +1028,7 @@ export default function FilePreviewPanel({ const wordWrap = wordWrapOverride ?? defaultWordWrap; const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const remoteOpenState = useRemoteOpenState(environmentId); + const remoteOpenResolution = useRemoteOpenResolution(environmentId); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const previewAvailable = usePreviewAvailable(environmentId); @@ -1283,7 +1284,12 @@ export default function FilePreviewPanel({ {absolutePath && - (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( + shouldShowOpenInPicker({ + environmentId, + primaryEnvironmentId, + remoteOpenMode: remoteOpenResolution.state.mode, + isSameHomeBearer: remoteOpenResolution.isSameHomeBearer, + }) ? ( ({ + presentationAtom: null as Atom.Writable | null, +})); +vi.mock(import("./state/presentation"), async () => { + const { Atom } = await import("effect/reactivity"); + state.presentationAtom = Atom.make(null); + return { environmentPresentations: { presentationAtom: () => state.presentationAtom! } } as never; +}); + +const environmentId = EnvironmentId.make("home-service"); +function Resolution() { + const resolution = useRemoteOpenResolution(environmentId); + return ( + + {resolution.state.mode}/{String(resolution.isSameHomeBearer)} + + ); +} + +it("marks only matching-home bearer execution and updates provenance when the mode stays local", async () => { + const getHomeEnvironmentId = vi.fn<() => Promise>().mockResolvedValue(null); + vi.stubGlobal("desktopBridge", { getHomeEnvironmentId }); + using _cleanup = { + [Symbol.dispose]() { + vi.unstubAllGlobals(); + appAtomRegistry.dispose(); + }, + }; + const presentation = (connectionId: string): EnvironmentPresentation => ({ + entry: { + target: new BearerConnectionTarget({ environmentId, label: "Service", connectionId }), + profile: Option.none(), + enabled: true, + }, + connection: { phase: "connected", error: null, traceId: null }, + serverConfig: null, + }); + appAtomRegistry.set(state.presentationAtom!, presentation("local:secondary")); + await using screen = await render( + + + , + ); + await expect.element(screen.getByRole("status")).toHaveTextContent("local-exec/false"); + await vi.waitFor(() => expect(getHomeEnvironmentId).toHaveResolvedWith(null)); + + getHomeEnvironmentId.mockResolvedValue(environmentId); + appAtomRegistry.set(state.presentationAtom!, presentation("paired-service")); + await expect.element(screen.getByRole("status")).toHaveTextContent("local-exec/true"); + + const ssh = presentation("paired-service"); + appAtomRegistry.set(state.presentationAtom!, { + ...ssh, + entry: { + ...ssh.entry, + target: new SshConnectionTarget({ environmentId, label: "SSH", connectionId: "ssh-service" }), + }, + }); + await expect.element(screen.getByRole("status")).toHaveTextContent("remote-unavailable/false"); +}); diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..ea93cb6af0e08 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -1,12 +1,18 @@ import { + BearerConnectionTarget, PrimaryConnectionTarget, RelayConnectionTarget, SshConnectionTarget, + type EnvironmentPresentation, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +import { readDesktopHomeEnvironmentId, resolveRemoteOpenState } from "./remoteOpen"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { environmentPresentations } from "./state/presentation"; const environmentId = EnvironmentId.make("environment-1"); @@ -30,6 +36,7 @@ describe("resolveRemoteOpenState", () => { target: primaryTarget("http://127.0.0.1:8000"), sshTarget: null, isDesktopRenderer: false, + desktopHomeEnvironmentId: null, remoteOpenTargets: TAILSCALE_TARGETS, }), ).toEqual({ mode: "local-exec" }); @@ -41,6 +48,7 @@ describe("resolveRemoteOpenState", () => { target: primaryTarget("https://sol.tail1234.ts.net"), sshTarget: null, isDesktopRenderer: false, + desktopHomeEnvironmentId: null, remoteOpenTargets: TAILSCALE_TARGETS, }), ).toEqual({ @@ -56,6 +64,7 @@ describe("resolveRemoteOpenState", () => { target: primaryTarget("http://172.29.112.1:14369"), sshTarget: null, isDesktopRenderer: true, + desktopHomeEnvironmentId: null, remoteOpenTargets: TAILSCALE_TARGETS, }), ).toEqual({ mode: "local-exec" }); @@ -66,6 +75,7 @@ describe("resolveRemoteOpenState", () => { target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), sshTarget: { alias: "sol", username: null }, isDesktopRenderer: true, + desktopHomeEnvironmentId: null, remoteOpenTargets: [ { kind: "tailscale", host: "sol.tail1234.ts.net", username: "server-user" }, ], @@ -96,6 +106,7 @@ describe("resolveRemoteOpenState", () => { target: new SshConnectionTarget({ environmentId, label: "server", connectionId: "ssh-1" }), sshTarget, isDesktopRenderer: true, + desktopHomeEnvironmentId: null, remoteOpenTargets: TAILSCALE_TARGETS, }); expect(state.mode).toBe("remote-links"); @@ -118,6 +129,7 @@ describe("resolveRemoteOpenState", () => { target: new RelayConnectionTarget({ environmentId, label: "server" }), sshTarget: null, isDesktopRenderer: false, + desktopHomeEnvironmentId: null, remoteOpenTargets: [ { kind: "tailscale", host: "server.tail1234.ts.net", username: "admin-user" }, ], @@ -129,6 +141,33 @@ describe("resolveRemoteOpenState", () => { ).toBe("cursor://vscode-remote/ssh-remote+admin-user%40server.tail1234.ts.net/work/project"); }); + it("keeps exec behavior for a paired server on the desktop's own T3 home", () => { + const pairedTarget = new BearerConnectionTarget({ + environmentId, + label: "laptop service", + connectionId: "paired-1", + }); + const input = { + target: pairedTarget, + sshTarget: null, + remoteOpenTargets: undefined, + isDesktopRenderer: true, + }; + expect(resolveRemoteOpenState({ ...input, desktopHomeEnvironmentId: environmentId })).toEqual({ + mode: "local-exec", + }); + expect(resolveRemoteOpenState({ ...input, desktopHomeEnvironmentId: "other-home" })).toEqual({ + mode: "remote-unavailable", + }); + expect( + resolveRemoteOpenState({ + ...input, + sshTarget: { alias: "laptop", username: null }, + desktopHomeEnvironmentId: environmentId, + }), + ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "laptop" } }); + }); + it("reports unavailable when a remote environment advertises no hosts", () => { for (const remoteOpenTargets of [[], undefined] as const) { expect( @@ -136,6 +175,7 @@ describe("resolveRemoteOpenState", () => { target: new RelayConnectionTarget({ environmentId, label: "sol" }), sshTarget: null, isDesktopRenderer: false, + desktopHomeEnvironmentId: null, remoteOpenTargets, }), ).toEqual({ mode: "remote-unavailable" }); @@ -186,3 +226,53 @@ describe("buildRemoteOpenUrl", () => { } }); }); + +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 741f6f3c60cde..d41985e50b0d0 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -13,6 +13,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isLoopbackHostname } from "~/environments/primary/target"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { environmentPresentations } from "~/state/presentation"; export interface RemoteOpenHost { @@ -31,6 +32,7 @@ export type RemoteOpenMode = RemoteOpenState["mode"]; export interface RemoteOpenResolution { readonly state: RemoteOpenState; readonly isResolved: boolean; + readonly isSameHomeBearer: boolean; } const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; @@ -38,6 +40,7 @@ const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = { state: LOCAL_EXEC, isResolved: false, + isSameHomeBearer: false, }; function parseHostname(url: string): string | null { @@ -53,6 +56,7 @@ export function resolveRemoteOpenState(input: { readonly sshTarget: { readonly alias: string; readonly username: string | null } | null; readonly remoteOpenTargets: ReadonlyArray | undefined; readonly isDesktopRenderer: boolean; + readonly desktopHomeEnvironmentId: string | null; }): RemoteOpenState { const { target } = input; if (target === null) { @@ -67,7 +71,13 @@ export function resolveRemoteOpenState(input: { if (hostname !== null && isLoopbackHostname(hostname)) { return LOCAL_EXEC; } - } else if (isDesktopLocalConnectionTarget(target)) { + } else if ( + // A background service paired from the desktop shares its T3 home and environment id. + (target._tag === "BearerConnectionTarget" && + input.sshTarget === null && + target.environmentId === input.desktopHomeEnvironmentId) || + isDesktopLocalConnectionTarget(target) + ) { return LOCAL_EXEC; } @@ -94,6 +104,34 @@ export function resolveRemoteOpenState(input: { return REMOTE_UNAVAILABLE; } +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(environmentId: EnvironmentId): string | null { + return appAtomRegistry.get(desktopHomeEnvironmentIdAtom(environmentId)); +} + function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState): boolean { if (previous.mode !== next.mode) return false; if (previous.mode !== "remote-links" || next.mode !== "remote-links") return true; @@ -113,19 +151,30 @@ const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) => return UNRESOLVED_REMOTE_OPEN; } const profile = Option.getOrNull(entry.profile); + const desktopHomeEnvironmentId = + entry.target._tag === "BearerConnectionTarget" + ? get(desktopHomeEnvironmentIdAtom(environmentId)) + : null; + const state = resolveRemoteOpenState({ + target: entry.target, + sshTarget: + profile !== null && profile._tag === "SshConnectionProfile" ? profile.target : null, + remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets, + isDesktopRenderer: window.desktopBridge !== undefined, + desktopHomeEnvironmentId, + }); const next: RemoteOpenResolution = { - state: resolveRemoteOpenState({ - target: entry.target, - sshTarget: - profile !== null && profile._tag === "SshConnectionProfile" ? profile.target : null, - remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets, - isDesktopRenderer: window.desktopBridge !== undefined, - }), + state, isResolved: true, + isSameHomeBearer: + state.mode === "local-exec" && + entry.target._tag === "BearerConnectionTarget" && + entry.target.environmentId === desktopHomeEnvironmentId, }; const previous = Option.getOrNull(get.self()); return previous !== null && previous.isResolved && + previous.isSameHomeBearer === next.isSameHomeBearer && remoteOpenStatesEqual(previous.state, next.state) ? previous : next; 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 8be2ccd51f03e..1fed95b81d10a 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 = []; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index afb87b14bc91f..2eaf161468c65 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1120,6 +1120,8 @@ export interface DesktopBridge { openSystemSettings: (pane: SystemSettingsPane) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; + /** The environment id stored in this desktop's T3 home; null before any server has written it. */ + getHomeEnvironmentId: () => Promise; pasteAsText: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void;