diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 2e12e00f45..f86d7c5a79 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -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().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().mockResolvedValueOnce( sessionResponse({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 53bfe2324b..1bb552e817 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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"; @@ -491,7 +491,7 @@ interface PersistentThreadTerminalDrawerProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; } -function PersistentThreadTerminalDrawer({ +const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, visible, @@ -650,7 +650,7 @@ function PersistentThreadTerminalDrawer({ /> ); -} +}); export default function ChatView(props: ChatViewProps) { const { environmentId, threadId, routeKind } = props; @@ -867,6 +867,14 @@ export default function ChatView(props: ChatViewProps) { [draftThreadsByThreadKey], ); const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const mountedTerminalThreadRefs = useMemo( + () => + mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedTerminalThreadKeys], + ); const setPrompt = useCallback( (nextPrompt: string) => { @@ -4850,28 +4858,22 @@ export default function ChatView(props: ChatViewProps) { {/* end horizontal flex container */} - {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - if (!mountedThreadRef) { - return []; - } - return [ - , - ]; - })} + {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + + ))} {expandedImage && expandedImageItem && (
{ } }); + 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); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9aac8ad6cc..c753508a06 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -268,6 +268,7 @@ export function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const environmentId = threadRef.environmentId; const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); @@ -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; @@ -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; @@ -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; @@ -743,7 +744,7 @@ export function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); + }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); return (
{ 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().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"); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 78e4b06604..2614164b6a 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -32,4 +32,4 @@ export { __resetServerAuthBootstrapForTests, } from "./auth"; -export { resolvePrimaryEnvironmentHttpUrl } from "./target"; +export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 2e83fc3887..3c0090b792 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -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; } @@ -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(); @@ -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(); diff --git a/apps/web/src/environments/runtime/service.test.ts b/apps/web/src/environments/runtime/service.test.ts new file mode 100644 index 0000000000..7a4af40498 --- /dev/null +++ b/apps/web/src/environments/runtime/service.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index a7083a2d7c..b53c9c5266 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -196,6 +196,17 @@ function reconcileSnapshotDerivedState() { useTerminalStateStore.getState().removeOrphanedTerminalStates(activeThreadKeys); } +export function shouldApplyTerminalEvent(input: { + serverThreadArchivedAt: string | null | undefined; + hasDraftThread: boolean; +}): boolean { + if (input.serverThreadArchivedAt !== undefined) { + return input.serverThreadArchivedAt === null; + } + + return input.hasDraftThread; +} + function applyRecoveredEventBatch( events: ReadonlyArray, environmentId: EnvironmentId, @@ -266,8 +277,15 @@ function createEnvironmentConnectionHandlers() { }, applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe(event.threadId)); - const thread = selectThreadByRef(useStore.getState(), threadRef); - if (!thread || thread.archivedAt !== null) { + const serverThread = selectThreadByRef(useStore.getState(), threadRef); + const hasDraftThread = + useComposerDraftStore.getState().getDraftThreadByRef(threadRef) !== null; + if ( + !shouldApplyTerminalEvent({ + serverThreadArchivedAt: serverThread?.archivedAt, + hasDraftThread, + }) + ) { return; } useTerminalStateStore.getState().applyTerminalEvent(threadRef, event); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a9d47df60e..7c123b4fed 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -72,6 +72,10 @@ export default defineConfig({ ...(devProxyTarget ? { proxy: { + "/.well-known": { + target: devProxyTarget, + changeOrigin: true, + }, "/api": { target: devProxyTarget, changeOrigin: true,