Skip to content
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
installRelayTelemetryEnabled,
getHomeEnvironmentId,
getSystemLocale,
getPreferredSystemLanguages,
getWindowFullscreenState,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +34,7 @@ import {
getLocalEnvironmentBootstraps,
installRelayTelemetryEnabled,
pickProjectFavicon,
getHomeEnvironmentId,
probeRemoteEditors,
readMedia,
} from "./window.ts";
Expand Down Expand Up @@ -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)),
);
16 changes: 16 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}.`;
Expand Down
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();
});
26 changes: 16 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down 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 @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
12 changes: 5 additions & 7 deletions apps/web/src/components/chat/OpenInPicker.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Loading
Loading