Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/web/src/components/ChatView.sameHome.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown, FakeDriver> }>(),
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 router={router} />, {
// 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();
});
9 changes: 6 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
40 changes: 27 additions & 13 deletions apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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()),
Expand All @@ -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()),
Expand Down Expand Up @@ -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]() {
Expand Down Expand Up @@ -134,17 +143,22 @@ function markdownPreview(truncated: boolean, availableEditors: ReadonlyArray<Edi
);
}

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" },
});
});
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 () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1287,7 +1287,8 @@ export default function FilePreviewPanel({
shouldShowOpenInPicker({
environmentId,
primaryEnvironmentId,
remoteOpenMode: remoteOpenState.mode,
remoteOpenMode: remoteOpenResolution.state.mode,
isSameHomeBearer: remoteOpenResolution.isSameHomeBearer,
}) ? (
<OpenInPicker
environmentId={environmentId}
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/remoteOpen.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
BearerConnectionTarget,
SshConnectionTarget,
type EnvironmentPresentation,
} from "@t3tools/client-runtime/connection";
import { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { Atom } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { render } from "../test/render";
import { useRemoteOpenResolution } from "./remoteOpen";
import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry";

const state = vi.hoisted(() => ({
presentationAtom: null as Atom.Writable<EnvironmentPresentation | null> | null,
}));
vi.mock(import("./state/presentation"), async () => {
const { Atom } = await import("effect/reactivity");
state.presentationAtom = Atom.make<EnvironmentPresentation | null>(null);
return { environmentPresentations: { presentationAtom: () => state.presentationAtom! } } as never;
});

const environmentId = EnvironmentId.make("home-service");
function Resolution() {
const resolution = useRemoteOpenResolution(environmentId);
return (
<output>
{resolution.state.mode}/{String(resolution.isSameHomeBearer)}
</output>
);
}

it("marks only matching-home bearer execution and updates provenance when the mode stays local", async () => {
const getHomeEnvironmentId = vi.fn<() => Promise<string | null>>().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(
<AppAtomRegistryProvider>
<Resolution />
</AppAtomRegistryProvider>,
);
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");
});
31 changes: 20 additions & 11 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ export type RemoteOpenMode = RemoteOpenState["mode"];
export interface RemoteOpenResolution {
readonly state: RemoteOpenState;
readonly isResolved: boolean;
readonly isSameHomeBearer: boolean;
}

const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
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 {
Expand Down Expand Up @@ -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<RemoteOpenResolution>());
return previous !== null &&
previous.isResolved &&
previous.isSameHomeBearer === next.isSameHomeBearer &&
remoteOpenStatesEqual(previous.state, next.state)
? previous
: next;
Expand Down