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
41 changes: 41 additions & 0 deletions apps/web/src/authBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,47 @@ describe("resolveInitialServerAuthGateState", () => {
});
});

it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
sessionResponse({
authenticated: false,
auth: {
policy: "desktop-managed-local",
bootstrapMethods: ["desktop-bootstrap"],
sessionMethods: ["browser-session-cookie"],
sessionCookieName: "t3_session",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");

const testWindow = installTestBrowser("http://127.0.0.1:5733/");
testWindow.desktopBridge = {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://127.0.0.1:3773",
wsBaseUrl: "ws://127.0.0.1:3773",
}),
} as DesktopBridge;

const { resolveInitialServerAuthGateState } = await import("./environments/primary");

await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
status: "requires-auth",
auth: {
policy: "desktop-managed-local",
bootstrapMethods: ["desktop-bootstrap"],
sessionMethods: ["browser-session-cookie"],
sessionCookieName: "t3_session",
},
});

expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", {
credentials: "include",
});
});

it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
sessionResponse({
Expand Down
52 changes: 27 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { truncate } from "@t3tools/shared/String";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
Expand Down Expand Up @@ -491,7 +491,7 @@ interface PersistentThreadTerminalDrawerProps {
onAddTerminalContext: (selection: TerminalContextSelection) => void;
}

function PersistentThreadTerminalDrawer({
const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
threadRef,
threadId,
visible,
Expand Down Expand Up @@ -650,7 +650,7 @@ function PersistentThreadTerminalDrawer({
/>
</div>
);
}
});

export default function ChatView(props: ChatViewProps) {
const { environmentId, threadId, routeKind } = props;
Expand Down Expand Up @@ -867,6 +867,14 @@ export default function ChatView(props: ChatViewProps) {
[draftThreadsByThreadKey],
);
const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
const mountedTerminalThreadRefs = useMemo(
() =>
mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : [];
}),
[mountedTerminalThreadKeys],
);

const setPrompt = useCallback(
(nextPrompt: string) => {
Expand Down Expand Up @@ -4850,28 +4858,22 @@ export default function ChatView(props: ChatViewProps) {
</div>
{/* end horizontal flex container */}

{mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
if (!mountedThreadRef) {
return [];
}
return [
<PersistentThreadTerminalDrawer
key={mountedThreadKey}
threadRef={mountedThreadRef}
threadId={mountedThreadRef.threadId}
visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
launchContext={
mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
}
focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
onAddTerminalContext={addTerminalContextToDraft}
/>,
];
})}
{mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
<PersistentThreadTerminalDrawer
key={mountedThreadKey}
threadRef={mountedThreadRef}
threadId={mountedThreadRef.threadId}
visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
launchContext={
mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
}
focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
onAddTerminalContext={addTerminalContextToDraft}
/>
))}

{expandedImage && expandedImageItem && (
<div
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,32 @@ describe("TerminalViewport", () => {
}
});

it("does not reopen the terminal when the scoped thread reference values stay the same", async () => {
const environment = createEnvironmentApi();
environmentApiById.set("environment-a", environment);

const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
});

try {
await vi.waitFor(() => {
expect(environment.terminal.open).toHaveBeenCalledTimes(1);
});

await mounted.rerender({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
});

await vi.waitFor(() => {
expect(environment.terminal.open).toHaveBeenCalledTimes(1);
});
expect(terminalDisposeSpy).not.toHaveBeenCalled();
} finally {
await mounted.cleanup();
}
});

it("uses the drawer surface colors for the terminal theme", async () => {
const environment = createEnvironmentApi();
environmentApiById.set("environment-a", environment);
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export function TerminalViewport({
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const environmentId = threadRef.environmentId;
const hasHandledExitRef = useRef(false);
const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
const selectionGestureActiveRef = useRef(false);
Expand All @@ -289,7 +290,7 @@ export function TerminalViewport({
if (!mount) return;

let disposed = false;
const api = readEnvironmentApi(threadRef.environmentId);
const api = readEnvironmentApi(environmentId);
const localApi = readLocalApi();
if (!api || !localApi) return;

Expand Down Expand Up @@ -706,7 +707,7 @@ export function TerminalViewport({
// autoFocus is intentionally omitted;
// it is only read at mount time and must not trigger terminal teardown/recreation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd, runtimeEnv, terminalId, threadId, threadRef]);
}, [cwd, environmentId, runtimeEnv, terminalId, threadId]);

useEffect(() => {
if (!autoFocus) return;
Expand All @@ -721,7 +722,7 @@ export function TerminalViewport({
}, [autoFocus, focusRequestId]);

useEffect(() => {
const api = readEnvironmentApi(threadRef.environmentId);
const api = readEnvironmentApi(environmentId);
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
if (!api || !terminal || !fitAddon) return;
Expand All @@ -743,7 +744,7 @@ export function TerminalViewport({
return () => {
window.cancelAnimationFrame(frame);
};
}, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
}, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]);
return (
<div
ref={containerRef}
Expand Down
15 changes: 3 additions & 12 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ import {
revokeOtherServerClientSessions,
revokeServerClientSession,
revokeServerPairingLink,
isLoopbackHostname,
type ServerClientSessionRecord,
type ServerPairingLinkRecord,
} from "../../environments/primary";
} from "~/environments/primary";
import type { WsRpcClient } from "~/rpc/wsRpcClient";
import {
type SavedEnvironmentRecord,
Expand All @@ -65,7 +66,7 @@ import {
getPrimaryEnvironmentConnection,
reconnectSavedEnvironment,
removeSavedEnvironment,
} from "../../environments/runtime";
} from "~/environments/runtime";

const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
Expand Down Expand Up @@ -253,16 +254,6 @@ function resolveCurrentOriginPairingUrl(credential: string): string {
return setPairingTokenOnUrl(url, credential).toString();
}

function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "[::1]"
);
}

type PairingLinkListRowProps = {
pairingLink: ServerPairingLinkRecord;
endpointUrl: string | null | undefined;
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/environments/primary/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,27 @@ describe("environmentBootstrap", () => {
await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment");
});

it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
vi.stubGlobal("window", {
location: new URL("http://127.0.0.1:5733/"),
history: {
replaceState: vi.fn(),
},
desktopBridge: {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://127.0.0.1:3773",
wsBaseUrl: "ws://127.0.0.1:3773",
bootstrapToken: "desktop-bootstrap-token",
}),
},
});

await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/.well-known/t3/environment");
});
});
2 changes: 1 addition & 1 deletion apps/web/src/environments/primary/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ export {
__resetServerAuthBootstrapForTests,
} from "./auth";

export { resolvePrimaryEnvironmentHttpUrl } from "./target";
export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
41 changes: 40 additions & 1 deletion apps/web/src/environments/primary/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface PrimaryEnvironmentTarget {
readonly target: KnownEnvironment["target"];
}

const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);

function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null {
return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null;
}
Expand All @@ -14,6 +16,43 @@ function normalizeBaseUrl(rawValue: string): string {
return new URL(rawValue, window.location.origin).toString();
}

function normalizeHostname(hostname: string): string {
return hostname
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, "$1");
}

export function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
}

function resolveHttpRequestBaseUrl(httpBaseUrl: string): string {
const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim();
if (!configuredDevServerUrl) {
return httpBaseUrl;
}

const currentUrl = new URL(window.location.href);
const targetUrl = new URL(httpBaseUrl);
const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin);

const isCurrentOriginDevServer =
(currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
currentUrl.origin === devServerUrl.origin;

if (
!isCurrentOriginDevServer ||
currentUrl.origin === targetUrl.origin ||
!isLoopbackHostname(currentUrl.hostname) ||
!isLoopbackHostname(targetUrl.hostname)
) {
return httpBaseUrl;
}

return currentUrl.origin;
}

function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim();
const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim();
Expand Down Expand Up @@ -86,7 +125,7 @@ export function resolvePrimaryEnvironmentHttpUrl(
throw new Error("Unable to resolve the primary environment HTTP base URL.");
}

const url = new URL(primaryTarget.target.httpBaseUrl);
const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl));
url.pathname = pathname;
if (searchParams) {
url.search = new URLSearchParams(searchParams).toString();
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/environments/runtime/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";

import { shouldApplyTerminalEvent } from "./service";

describe("shouldApplyTerminalEvent", () => {
it("applies terminal events for draft-only threads", () => {
expect(
shouldApplyTerminalEvent({
serverThreadArchivedAt: undefined,
hasDraftThread: true,
}),
).toBe(true);
});

it("drops terminal events for unknown threads", () => {
expect(
shouldApplyTerminalEvent({
serverThreadArchivedAt: undefined,
hasDraftThread: false,
}),
).toBe(false);
});

it("drops terminal events for archived server threads even if a draft exists", () => {
expect(
shouldApplyTerminalEvent({
serverThreadArchivedAt: "2026-04-09T00:00:00.000Z",
hasDraftThread: true,
}),
).toBe(false);
});

it("applies terminal events for active server threads", () => {
expect(
shouldApplyTerminalEvent({
serverThreadArchivedAt: null,
hasDraftThread: false,
}),
).toBe(true);
});
});
Loading
Loading