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 b6b99410e1a2c..df6c7a5f41646 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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 { @@ -1646,13 +1646,16 @@ export default function ChatView(props: ChatViewProps) { const availableEditors = useAtomValue( serverAvailableEditorsAtom(activeThread?.environmentId ?? environmentId), ); - const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); + const remoteOpenResolution = useRemoteOpenResolution( + activeThread?.environmentId ?? environmentId, + ); const showOpenInPicker = Boolean(activeProject?.title) && shouldShowOpenInPicker({ environmentId: activeThread?.environmentId ?? environmentId, primaryEnvironmentId, - remoteOpenMode: remoteOpenState.mode, + remoteOpenMode: remoteOpenResolution.state.mode, + isSameHomeBearer: remoteOpenResolution.isSameHomeBearer, }); useOpenFavoriteEditorShortcut({ enabled: showOpenInPicker && !backgroundThread, diff --git a/apps/web/src/components/chat/OpenInPicker.logic.test.ts b/apps/web/src/components/chat/OpenInPicker.logic.test.ts index bcf8629f8efae..5349b93a8e7dd 100644 --- a/apps/web/src/components/chat/OpenInPicker.logic.test.ts +++ b/apps/web/src/components/chat/OpenInPicker.logic.test.ts @@ -10,12 +10,19 @@ 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, }), diff --git a/apps/web/src/components/chat/OpenInPicker.logic.ts b/apps/web/src/components/chat/OpenInPicker.logic.ts index 30ca38b4070af..54906ba55dc87 100644 --- a/apps/web/src/components/chat/OpenInPicker.logic.ts +++ b/apps/web/src/components/chat/OpenInPicker.logic.ts @@ -6,10 +6,12 @@ export function shouldShowOpenInPicker(input: { readonly environmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; readonly remoteOpenMode: RemoteOpenMode; + readonly isSameHomeBearer: boolean; }): boolean { 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 1bfaf77edc95d..9dcdb0502ea25 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -12,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) => ({ @@ -26,7 +28,7 @@ vi.mock(import("~/state/session"), async (original) => ({ })); vi.mock(import("~/state/environments"), async (original) => ({ ...(await original()), - usePrimaryEnvironmentId: () => null, + usePrimaryEnvironmentId: () => state.primaryEnvironmentId, })); vi.mock(import("~/state/filesystem"), async (original) => ({ ...(await original()), @@ -48,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()), @@ -92,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]() { @@ -134,17 +143,22 @@ function markdownPreview(truncated: boolean, availableEditors: ReadonlyArray { - 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" }, - }); -}); +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 () => { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 2812fcdb56a03..3322dbba439c9 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,7 +57,7 @@ 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"; @@ -1028,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); @@ -1287,7 +1287,8 @@ export default function FilePreviewPanel({ shouldShowOpenInPicker({ environmentId, primaryEnvironmentId, - remoteOpenMode: remoteOpenState.mode, + 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.ts b/apps/web/src/remoteOpen.ts index 15266e2e3b3cb..d41985e50b0d0 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -32,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" }; @@ -39,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 { @@ -149,23 +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, - desktopHomeEnvironmentId: - entry.target._tag === "BearerConnectionTarget" - ? get(desktopHomeEnvironmentIdAtom(environmentId)) - : null, - }), + 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;