diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx
index 5e5b5ff16be90..e62b89a40e42b 100644
--- a/apps/web/src/AppRoot.tsx
+++ b/apps/web/src/AppRoot.tsx
@@ -5,7 +5,6 @@ import { subscribeComposerDraftModelReconciliation } from "./composerDraftStore"
import { environmentThreadShells } from "./state/threads";
import { environmentShell } from "./state/shell";
-import { BrowserProfileReporter } from "./browser/BrowserProfileReporter";
import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry";
@@ -27,7 +26,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
-
);
diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx
index 55cea5c1146e8..3f909b26ff475 100644
--- a/apps/web/src/appInit.tsx
+++ b/apps/web/src/appInit.tsx
@@ -5,6 +5,8 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"
import { applyAppearanceContrast } from "./appearanceContrast";
import { applyAppearanceFontVariables } from "./appearanceFonts";
import { startElectronBrowserHost } from "./browser/ElectronBrowserHost";
+import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter";
+import { startPreviewTitleRecording } from "./browserHistoryStore";
import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention";
import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer";
import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog";
@@ -144,6 +146,8 @@ export function startAppServices(): AppRouter {
startModelPreferencesMigration();
startAgentOpenedPreviews();
if (isElectron) startElectronBrowserHost();
+ startPreviewTitleRecording();
+ startBrowserProfileReporting();
startThreadOverlayRetention(router);
startMainAppLocationTracking(router);
startBackgroundThreadAnnouncements(appAtomRegistry, router);
diff --git a/apps/web/src/browser/BrowserProfileReporter.test.ts b/apps/web/src/browser/BrowserProfileReporter.test.ts
new file mode 100644
index 0000000000000..cd715c8120229
--- /dev/null
+++ b/apps/web/src/browser/BrowserProfileReporter.test.ts
@@ -0,0 +1,64 @@
+import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
+import { EnvironmentId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ type ProfileReport,
+ type ReportedProfiles,
+ takeDueProfileReports,
+} from "./BrowserProfileReporter";
+
+const first = EnvironmentId.make("environment-1");
+const second = EnvironmentId.make("environment-2");
+const config = (label: string) => ({ label }) as unknown as EnvironmentServerConfig;
+const input = (recordingFrameRate: number) =>
+ ({ profiles: [], defaultProfileId: "default", recordingFrameRate }) as unknown as ProfileReport;
+
+describe("takeDueProfileReports", () => {
+ it("reports each environment once per config and input", () => {
+ const reported: ReportedProfiles = new Map();
+ const configA = config("a");
+ const configB = config("b");
+ const settings = input(10);
+ const targets = new Map([
+ [first, configA],
+ [second, configB],
+ ]);
+
+ expect(takeDueProfileReports(reported, targets, settings)).toEqual([first, second]);
+ expect(takeDueProfileReports(reported, new Map(targets), settings)).toEqual([]);
+
+ const restarted = config("a-restarted");
+ expect(
+ takeDueProfileReports(
+ reported,
+ new Map([
+ [first, restarted],
+ [second, configB],
+ ]),
+ settings,
+ ),
+ ).toEqual([first]);
+ });
+
+ it("reports every target again when the profile settings change", () => {
+ const reported: ReportedProfiles = new Map();
+ const targets = new Map([
+ [first, config("a")],
+ [second, config("b")],
+ ]);
+ takeDueProfileReports(reported, targets, input(10));
+
+ expect(takeDueProfileReports(reported, targets, input(30))).toEqual([first, second]);
+ });
+
+ it("reports an environment again once it becomes operable again", () => {
+ const reported: ReportedProfiles = new Map();
+ const configA = config("a");
+ const settings = input(10);
+ takeDueProfileReports(reported, new Map([[first, configA]]), settings);
+
+ expect(takeDueProfileReports(reported, new Map(), settings)).toEqual([]);
+ expect(takeDueProfileReports(reported, new Map([[first, configA]]), settings)).toEqual([first]);
+ });
+});
diff --git a/apps/web/src/browser/BrowserProfileReporter.ts b/apps/web/src/browser/BrowserProfileReporter.ts
new file mode 100644
index 0000000000000..89d3f8be69d33
--- /dev/null
+++ b/apps/web/src/browser/BrowserProfileReporter.ts
@@ -0,0 +1,106 @@
+import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
+import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
+import { AuthPreviewOperateScope, type EnvironmentId } from "@t3tools/contracts";
+import type { ClientSettings } from "@t3tools/contracts/settings";
+import { Atom } from "effect/reactivity";
+
+import {
+ getClientSettings,
+ getClientSettingsHydratedSnapshot,
+ subscribeClientSettings,
+ subscribeClientSettingsHydration,
+} from "~/hooks/useSettings";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { environmentPresentations } from "~/state/presentation";
+import { previewEnvironment } from "~/state/preview";
+import { environmentSession, sessionHasScope } from "~/state/session";
+
+export interface ProfileReport {
+ readonly profiles: ClientSettings["browserProfiles"];
+ readonly defaultProfileId: ClientSettings["browserDefaultProfileId"];
+ readonly recordingFrameRate: ClientSettings["browserRecordingFrameRate"];
+}
+
+/** Server-browser environments this client may operate, with the config each currently runs. */
+const reportTargetsAtom = Atom.make((get) => {
+ const targets = new Map();
+ for (const [environmentId, config] of get(environmentPresentations.serverConfigsAtom)) {
+ if (!config.environment.capabilities.serverBrowser) continue;
+ const session = get(environmentSession.sessionStateAtom(environmentId));
+ if (!sessionHasScope(session, AuthPreviewOperateScope)) continue;
+ targets.set(environmentId, config);
+ }
+ return targets;
+}).pipe(Atom.withLabel("browser-profile-report-targets"));
+
+/**
+ * Keeps every operable server browser on this client's profiles. An environment is reported
+ * again when its server config changes (a restarted server has forgotten them), when it becomes
+ * operable, and when the profile settings change.
+ */
+export function startBrowserProfileReporting(): () => void {
+ const reported: ReportedProfiles = new Map();
+ const report = () => {
+ if (!getClientSettingsHydratedSnapshot()) return;
+ const settings = getClientSettings();
+ const input: ProfileReport = {
+ profiles: settings.browserProfiles,
+ defaultProfileId: settings.browserDefaultProfileId,
+ recordingFrameRate: settings.browserRecordingFrameRate,
+ };
+ const targets = appAtomRegistry.get(reportTargetsAtom);
+ for (const environmentId of takeDueProfileReports(reported, targets, input)) {
+ void runAtomCommand(
+ appAtomRegistry,
+ previewEnvironment.reportProfiles,
+ { environmentId, input },
+ { reportFailure: false },
+ );
+ }
+ };
+ const unsubscribes = [
+ appAtomRegistry.subscribe(reportTargetsAtom, report),
+ subscribeClientSettings(report),
+ subscribeClientSettingsHydration(report),
+ ];
+ report();
+ return () => {
+ for (const unsubscribe of unsubscribes) unsubscribe();
+ };
+}
+
+export type ReportedProfiles = Map<
+ EnvironmentId,
+ { readonly config: EnvironmentServerConfig; readonly input: ProfileReport }
+>;
+
+/**
+ * Returns the targets that need `input` reported and records them in `reported`. An
+ * environment that dropped out of `targets` is forgotten, so it is reported again once it
+ * becomes operable.
+ */
+export function takeDueProfileReports(
+ reported: ReportedProfiles,
+ targets: ReadonlyMap,
+ input: ProfileReport,
+): EnvironmentId[] {
+ for (const environmentId of reported.keys()) {
+ if (!targets.has(environmentId)) reported.delete(environmentId);
+ }
+ const due: EnvironmentId[] = [];
+ for (const [environmentId, config] of targets) {
+ const last = reported.get(environmentId);
+ if (last && last.config === config && sameReportInput(last.input, input)) continue;
+ reported.set(environmentId, { config, input });
+ due.push(environmentId);
+ }
+ return due;
+}
+
+function sameReportInput(left: ProfileReport, right: ProfileReport): boolean {
+ return (
+ left.profiles === right.profiles &&
+ left.defaultProfileId === right.defaultProfileId &&
+ left.recordingFrameRate === right.recordingFrameRate
+ );
+}
diff --git a/apps/web/src/browser/BrowserProfileReporter.tsx b/apps/web/src/browser/BrowserProfileReporter.tsx
deleted file mode 100644
index 1839a35aac213..0000000000000
--- a/apps/web/src/browser/BrowserProfileReporter.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import {
- AuthPreviewOperateScope,
- type BrowserProfile,
- type BrowserRecordingFrameRate,
-} from "@t3tools/contracts";
-import { useEffect } from "react";
-
-import { useClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings";
-import { useServerConfigs } from "~/state/entities";
-import { useEnvironments } from "~/state/environments";
-import { previewEnvironment } from "~/state/preview";
-import { useEnvironmentsWithScope } from "~/state/session";
-import { useAtomCommand } from "~/state/use-atom-command";
-
-const selectProfiles = (settings: { readonly browserProfiles: ReadonlyArray }) =>
- settings.browserProfiles;
-const selectDefaultProfileId = (settings: { readonly browserDefaultProfileId: string }) =>
- settings.browserDefaultProfileId;
-const selectRecordingFrameRate = (settings: {
- readonly browserRecordingFrameRate: BrowserRecordingFrameRate;
-}) => settings.browserRecordingFrameRate;
-
-export function BrowserProfileReporter() {
- const hydrated = useClientSettingsHydrated();
- const profiles = useClientSettings(selectProfiles);
- const defaultProfileId = useClientSettings(selectDefaultProfileId);
- const recordingFrameRate = useClientSettings(selectRecordingFrameRate);
- const { environments } = useEnvironments();
- const serverConfigs = useServerConfigs();
- const operable = useEnvironmentsWithScope(environments, AuthPreviewOperateScope);
- const report = useAtomCommand(previewEnvironment.reportProfiles, { reportFailure: false });
-
- useEffect(() => {
- if (!hydrated) return;
- for (const [environmentId, config] of serverConfigs) {
- if (!config.environment.capabilities.serverBrowser || !operable.has(environmentId)) continue;
- void report({ environmentId, input: { profiles, defaultProfileId, recordingFrameRate } });
- }
- }, [defaultProfileId, hydrated, operable, profiles, recordingFrameRate, report, serverConfigs]);
-
- return null;
-}
diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 2865b52036020..cd1852d369279 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -236,7 +236,7 @@ export function HostedBrowserWebview(props: {
handleResizePointerDown,
layout: viewportLayout,
} = useBrowserViewportResize({
- tabId: runtimeTabId,
+ target: { threadRef, tabId, runtimeTabId },
viewport,
zoomFactor: layoutZoomFactor,
containerSize,
diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx
index 93c9cbcc9b26c..45c84dba0e71f 100644
--- a/apps/web/src/browser/ServerBrowserSurface.tsx
+++ b/apps/web/src/browser/ServerBrowserSurface.tsx
@@ -49,7 +49,6 @@ export interface ServerBrowserHandle {
}
const RESIZE_THROTTLE_MS = 150;
-const ACCESS_RETRY_MS = 10_000;
// Reuse nearby probes when a slow link would return the tap's answer after it ends.
const PROBE_REUSE_PX = 24;
const PROBE_REUSE_MS = 10_000;
@@ -161,7 +160,6 @@ export function ServerBrowserSurface(props: {
pdfUrl = null,
ref,
} = props;
- const access = usePreviewStreamAccess(environmentId);
const canvasRef = useRef(null);
const inputRef = useRef(null);
const keySentRef = useRef(false);
@@ -189,6 +187,7 @@ export function ServerBrowserSurface(props: {
};
const unauthorizedRef = useRef(0);
const [accessDenied, setAccessDenied] = useState(false);
+ const access = usePreviewStreamAccess(environmentId, { retry: visible && !accessDenied });
const [hostSetup, setHostSetup] = useState(null);
const pendingMoveRef = useRef(null);
const pendingWheelRef = useRef(null);
@@ -458,12 +457,6 @@ export function ServerBrowserSurface(props: {
visible,
]);
- useEffect(() => {
- if (!visible || accessDenied || access !== null) return;
- const timer = setInterval(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS);
- return () => clearInterval(timer);
- }, [access, accessDenied, environmentId, visible]);
-
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts
index a441eecf08ddb..a744305330dbe 100644
--- a/apps/web/src/browser/browserViewportActions.test.ts
+++ b/apps/web/src/browser/browserViewportActions.test.ts
@@ -1,9 +1,30 @@
-import { describe, expect, it } from "vite-plus/test";
+import { describe, expect, it, vi } from "vite-plus/test";
import {
- commitBrowserViewportChange,
- subscribeBrowserViewportChange,
-} from "./browserViewportActions";
+ EnvironmentId,
+ type PreviewViewportSetting,
+ ThreadId,
+ type PreviewSessionSnapshot,
+} from "@t3tools/contracts";
+
+const mocks = vi.hoisted(() => ({
+ resize: Symbol("preview-resize"),
+ runAtomCommand: vi.fn(),
+ updatePreviewServerSnapshot: vi.fn(),
+}));
+
+vi.mock("@t3tools/client-runtime/state/runtime", () => ({
+ runAtomCommand: mocks.runAtomCommand,
+ squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
+}));
+vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
+vi.mock("~/state/preview", () => ({ previewEnvironment: { resize: mocks.resize } }));
+vi.mock("~/previewStateStore", () => ({
+ updatePreviewServerSnapshot: mocks.updatePreviewServerSnapshot,
+}));
+vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } }));
+
+import { commitBrowserViewportChange, queueBrowserViewportCommit } from "./browserViewportActions";
describe("browserViewportActions", () => {
it("commits viewport changes in order for each tab", async () => {
@@ -16,31 +37,52 @@ describe("browserViewportActions", () => {
markFirstStarted = resolve;
});
const calls: Array = [];
- const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => {
+ const resize = async (setting: PreviewViewportSetting) => {
if (setting._tag === "fill") return;
calls.push(setting.width);
if (setting.width === 800) {
markFirstStarted?.();
await firstPending;
}
- });
+ };
- const first = commitBrowserViewportChange("tab-serial", {
- _tag: "freeform",
- width: 800,
- height: 600,
- });
- const second = commitBrowserViewportChange("tab-serial", {
- _tag: "freeform",
- width: 900,
- height: 700,
- });
+ const first = queueBrowserViewportCommit("tab-serial", () =>
+ resize({ _tag: "freeform", width: 800, height: 600 }),
+ );
+ const second = queueBrowserViewportCommit("tab-serial", () =>
+ resize({ _tag: "freeform", width: 900, height: 700 }),
+ );
await firstStarted;
expect(calls).toEqual([800]);
releaseFirst?.();
await Promise.all([first, second]);
expect(calls).toEqual([800, 900]);
- unsubscribe();
+ });
+
+ it("resizes a tab that no preview panel is showing", async () => {
+ const threadRef = {
+ environmentId: EnvironmentId.make("env-1"),
+ threadId: ThreadId.make("thread-1"),
+ };
+ const snapshot = { tabId: "tab-1" } as PreviewSessionSnapshot;
+ mocks.runAtomCommand.mockResolvedValueOnce({ _tag: "Success", value: snapshot });
+ const viewport: PreviewViewportSetting = { _tag: "freeform", width: 390, height: 844 };
+
+ await commitBrowserViewportChange(
+ { threadRef, tabId: "tab-1", runtimeTabId: "runtime-tab-1" },
+ viewport,
+ );
+
+ expect(mocks.runAtomCommand).toHaveBeenCalledWith(
+ expect.anything(),
+ mocks.resize,
+ {
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId, tabId: "tab-1", viewport },
+ },
+ expect.anything(),
+ );
+ expect(mocks.updatePreviewServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot);
});
});
diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts
index a1dc59f6dbf18..eddee7f5b1e75 100644
--- a/apps/web/src/browser/browserViewportActions.ts
+++ b/apps/web/src/browser/browserViewportActions.ts
@@ -1,6 +1,17 @@
-import type { PreviewViewportSetting } from "@t3tools/contracts";
+import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
-type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise;
+import { toastManager } from "~/components/ui/toast";
+import { updatePreviewServerSnapshot } from "~/previewStateStore";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { previewEnvironment } from "~/state/preview";
+
+/** One browser tab as both the server (thread + tab id) and this window's runtime know it. */
+export interface BrowserViewportTarget {
+ readonly threadRef: ScopedThreadRef;
+ readonly tabId: string;
+ readonly runtimeTabId: string;
+}
export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
@@ -12,7 +23,6 @@ class BrowserViewportCommitTimeoutError extends Error {
}
}
-const handlers = new Map();
const commitTails = new Map>();
const queueBrowserViewportMutation = (
@@ -59,27 +69,49 @@ const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise
});
};
-export function subscribeBrowserViewportChange(
- tabId: string,
- handler: BrowserViewportHandler,
-): () => void {
- handlers.set(tabId, handler);
- return () => {
- if (handlers.get(tabId) === handler) handlers.delete(tabId);
- };
+async function resizeBrowserViewport(
+ target: BrowserViewportTarget,
+ viewport: PreviewViewportSetting,
+): Promise {
+ const result = await runAtomCommand(
+ appAtomRegistry,
+ previewEnvironment.resize,
+ {
+ environmentId: target.threadRef.environmentId,
+ input: { threadId: target.threadRef.threadId, tabId: target.tabId, viewport },
+ },
+ { label: "preview viewport resize" },
+ );
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add({
+ type: "error",
+ title: "Unable to resize browser viewport",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ throw error;
+ }
+ updatePreviewServerSnapshot(target.threadRef, result.value);
+}
+
+/**
+ * Runs `operation` after every earlier commit for the runtime tab. The
+ * caller-facing timeout starts only once it reaches the front of the queue,
+ * while the queue itself waits for the real operation.
+ */
+export function queueBrowserViewportCommit(
+ runtimeTabId: string,
+ operation: () => Promise,
+): Promise {
+ const { started } = queueBrowserViewportMutation(runtimeTabId, operation);
+ return started.then(({ operation: running }) => runHandlerWithTimeout(runtimeTabId, running));
}
export function commitBrowserViewportChange(
- tabId: string,
+ target: BrowserViewportTarget,
setting: PreviewViewportSetting,
): Promise {
- const { started } = queueBrowserViewportMutation(tabId, () => {
- const handler = handlers.get(tabId);
- return handler
- ? handler(setting)
- : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));
- });
- // Queue until the handler finishes; start the caller timeout only when its turn begins.
- const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation));
- return result;
+ return queueBrowserViewportCommit(target.runtimeTabId, () =>
+ resizeBrowserViewport(target, setting),
+ );
}
diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts
index bee012aa6d70d..6fc24b546896c 100644
--- a/apps/web/src/browser/useBrowserViewportResize.ts
+++ b/apps/web/src/browser/useBrowserViewportResize.ts
@@ -8,7 +8,7 @@ import {
} from "react";
import { useStableCallback } from "../hooks/useStableCallback";
-import { commitBrowserViewportChange } from "./browserViewportActions";
+import { type BrowserViewportTarget, commitBrowserViewportChange } from "./browserViewportActions";
import {
browserViewportSettingKey,
resizeBrowserViewportFromRail,
@@ -27,14 +27,15 @@ interface ViewportDrag extends PreviewViewportSize {
const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150;
export function useBrowserViewportResize(options: {
- readonly tabId: string;
+ readonly target: BrowserViewportTarget;
readonly viewport: PreviewViewportSetting;
readonly zoomFactor: number;
readonly containerSize: PreviewViewportSize;
readonly deviceToolbarVisible: boolean;
readonly aspectRatio: number | null;
}) {
- const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options;
+ const { target, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } =
+ options;
const dragCleanupRef = useRef<(() => void) | null>(null);
const dragVersionRef = useRef(0);
const keyboardCommitTimerRef = useRef | null>(null);
@@ -91,7 +92,7 @@ export function useBrowserViewportResize(options: {
}
keyboardViewportRef.current = null;
setDragViewport(null);
- return commitBrowserViewportChange(tabId, next);
+ return commitBrowserViewportChange(target, next);
};
const clearDrag = () => setDragViewport(null);
@@ -100,7 +101,7 @@ export function useBrowserViewportResize(options: {
const clearIfCurrent = () => {
if (dragVersionRef.current === version) clearDrag();
};
- void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent);
+ void commitBrowserViewportChange(target, next).then(clearIfCurrent, clearIfCurrent);
};
const handleResizeKeyDown = (
diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts
index 473eeadf5e24e..554faf086d5d4 100644
--- a/apps/web/src/browserHistoryStore.test.ts
+++ b/apps/web/src/browserHistoryStore.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
const { readPreparedConnection } = vi.hoisted(() => ({
readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null),
@@ -16,10 +16,11 @@ import {
normalizeHistoryUrl,
recordVisitForThread,
resetBrowserHistoryForTests,
- setTitleForThreadUrl,
+ startPreviewTitleRecording,
upsertHistoryEntry,
useBrowserHistoryStore,
} from "./browserHistoryStore";
+import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore";
function entry(overrides: Partial = {}): BrowserHistoryEntry {
return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides };
@@ -108,24 +109,107 @@ const threadRef = {
threadId: ThreadId.make("thread-1"),
};
+let settledAt = 0;
+
+function settle(url: string, title: string, tabId = "tab_a", ref = threadRef): void {
+ settledAt += 1;
+ const snapshot: PreviewSessionSnapshot = {
+ threadId: ref.threadId,
+ tabId,
+ navStatus: { _tag: "Success", url, title },
+ canGoBack: false,
+ canGoForward: false,
+ updatedAt: new Date(settledAt).toISOString(),
+ };
+ applyPreviewServerSnapshot(ref, snapshot);
+}
+
+function resetStores(): () => void {
+ resetBrowserHistoryForTests();
+ resetPreviewStateForTests();
+ return startPreviewTitleRecording();
+}
+
describe("useBrowserHistoryStore", () => {
- beforeEach(() => {
- resetBrowserHistoryForTests();
- });
+ beforeEach(resetStores);
- it("sets titles update-only via the thread helper", () => {
+ it("titles only pages already in history when they settle", () => {
useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
- setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create");
+ settle("http://a.test/", "Should not create");
expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
recordVisitForThread(threadRef, "http://a.test/#/settings", 1);
- setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App");
+ settle("http://a.test/#/settings", "My App");
expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App");
});
+ it("titles a visit recorded after its page already settled", () => {
+ useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
+ recordVisitForThread(threadRef, "http://other.test/", 1);
+ settle("http://a.test/", "My App");
+ recordVisitForThread(threadRef, "http://a.test/", 2);
+ expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
+ url: "http://a.test/",
+ title: "My App",
+ });
+ });
+
+ it("keeps the newest title when another tab shows the same url", () => {
+ useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
+ recordVisitForThread(threadRef, "http://a.test/", 1);
+ settle("http://a.test/", "Old", "tab_a");
+ settle("http://a.test/", "Old", "tab_b");
+ settle("http://a.test/", "New", "tab_a");
+ expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
+ });
+
+ it("keeps another thread's newer title when an unrelated tab settles", () => {
+ const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
+ const store = useBrowserHistoryStore.getState();
+ store.registerThreadProject(threadRef, "proj-a");
+ store.registerThreadProject(otherThreadRef, "proj-a");
+ recordVisitForThread(threadRef, "http://a.test/", 1);
+ settle("http://a.test/", "Old", "tab_a", threadRef);
+ settle("http://a.test/", "New", "tab_a", otherThreadRef);
+ settle("http://b.test/", "Unrelated", "tab_b", threadRef);
+ expect(
+ useBrowserHistoryStore
+ .getState()
+ .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title,
+ ).toBe("New");
+ });
+
+ it("keeps another thread's newer title when this thread records a different visit", () => {
+ const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
+ const store = useBrowserHistoryStore.getState();
+ store.registerThreadProject(threadRef, "proj-a");
+ store.registerThreadProject(otherThreadRef, "proj-a");
+ recordVisitForThread(threadRef, "http://a.test/", 1);
+ settle("http://a.test/", "Old", "tab_a", threadRef);
+ settle("http://a.test/", "New", "tab_a", otherThreadRef);
+ recordVisitForThread(threadRef, "http://b.test/", 2);
+ expect(
+ useBrowserHistoryStore
+ .getState()
+ .byProjectKey["proj-a"]?.find((e) => e.url === "http://a.test/")?.title,
+ ).toBe("New");
+ });
+
+ it("keeps another thread's newer title when this thread revisits the same url", () => {
+ const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
+ const store = useBrowserHistoryStore.getState();
+ store.registerThreadProject(threadRef, "proj-a");
+ store.registerThreadProject(otherThreadRef, "proj-a");
+ recordVisitForThread(threadRef, "http://a.test/", 1);
+ settle("http://a.test/", "Old", "tab_a", threadRef);
+ settle("http://a.test/", "New", "tab_a", otherThreadRef);
+ recordVisitForThread(threadRef, "http://a.test/", 2);
+ expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
+ });
+
it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => {
useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
recordVisitForThread(threadRef, "http://a.test/community", 1);
- setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community");
+ settle("http://a.test/community/", "Community");
expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
url: "http://a.test/community",
title: "Community",
@@ -133,7 +217,7 @@ describe("useBrowserHistoryStore", () => {
useBrowserHistoryStore.setState({ byProjectKey: {} });
recordVisitForThread(threadRef, "http://a.test/community/", 1);
- setTitleForThreadUrl(threadRef, "http://a.test/community", "Community");
+ settle("http://a.test/community", "Community");
expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({
url: "http://a.test/community/",
title: "Community",
@@ -143,7 +227,8 @@ describe("useBrowserHistoryStore", () => {
it("matches a requested localhost URL to the resolved environment host", () => {
useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
recordVisitForThread(threadRef, "http://localhost:5173/app", 1);
- setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2");
+ readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" });
+ settle("http://192.168.64.2:5173/app", "Local App");
expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App");
});
@@ -167,9 +252,7 @@ describe("useBrowserHistoryStore", () => {
});
describe("pendingVisitsByThreadKey", () => {
- beforeEach(() => {
- resetBrowserHistoryForTests();
- });
+ beforeEach(resetStores);
it("queues a visit recorded before registration and drains it in order on registration", () => {
recordVisitForThread(threadRef, "http://a.test/", 1);
@@ -201,21 +284,31 @@ describe("pendingVisitsByThreadKey", () => {
});
});
-describe("pendingTitlesByThreadKey", () => {
- beforeEach(() => {
- resetBrowserHistoryForTests();
- });
+describe("titles before registration", () => {
+ beforeEach(resetStores);
- it("buffers a title set before registration and applies it once the matching visit drains", () => {
+ it("applies a settled page's title once the matching visit drains", () => {
recordVisitForThread(threadRef, "http://a.test/", 1);
- setTitleForThreadUrl(threadRef, "http://a.test/", "My App");
+ settle("http://a.test/", "My App");
expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({});
useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"];
expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" });
- expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({});
+ });
+
+ it("keeps another thread's title when a queued visit to the same url drains", () => {
+ const otherThreadRef = { ...threadRef, threadId: ThreadId.make("thread-2") };
+ recordVisitForThread(threadRef, "http://a.test/", 1);
+ settle("http://a.test/", "Old", "tab_a", threadRef);
+ useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a");
+ recordVisitForThread(otherThreadRef, "http://a.test/", 2);
+ settle("http://a.test/", "New", "tab_a", otherThreadRef);
+
+ useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a");
+
+ expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("New");
});
});
@@ -238,6 +331,5 @@ describe("mergeBrowserHistoryState", () => {
expect(typeof merged.recordVisit).toBe("function");
expect(merged.projectKeyByThreadKey).toEqual({ good: "b" });
expect(merged.pendingVisitsByThreadKey).toEqual({});
- expect(merged.pendingTitlesByThreadKey).toEqual({});
});
});
diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts
index 7909fef957001..33a9b0dc52390 100644
--- a/apps/web/src/browserHistoryStore.ts
+++ b/apps/web/src/browserHistoryStore.ts
@@ -1,10 +1,17 @@
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
-import type { ScopedThreadRef } from "@t3tools/contracts";
+import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { useShallow } from "zustand/react/shallow";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
+import {
+ previewStateAtom,
+ previewThreadRefsAtom,
+ readThreadPreviewState,
+ type ThreadPreviewState,
+} from "~/previewStateStore";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
import { readPreparedConnection } from "~/state/session";
import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
@@ -144,13 +151,11 @@ const PENDING_MAX_PER_THREAD = 10;
const PENDING_MAX_THREADS = 20;
type PendingVisit = { url: string; at: number; environmentHostname: string | null };
-type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined };
interface BrowserHistoryStoreState {
byProjectKey: Record;
projectKeyByThreadKey: Record;
pendingVisitsByThreadKey: Record;
- pendingTitlesByThreadKey: Record;
recordVisit: (
projectKey: string,
url: string,
@@ -189,7 +194,6 @@ export const useBrowserHistoryStore = create()(
byProjectKey: {},
projectKeyByThreadKey: {},
pendingVisitsByThreadKey: {},
- pendingTitlesByThreadKey: {},
recordVisit: (projectKey, url, at, options) => {
const normalized = normalizeHistoryUrl(url);
if (!normalized) return;
@@ -245,35 +249,22 @@ export const useBrowserHistoryStore = create()(
const threadKey = scopedThreadKey(ref);
const state = get();
const pendingVisits = state.pendingVisitsByThreadKey[threadKey];
- const pendingTitles = state.pendingTitlesByThreadKey[threadKey];
- if (
- state.projectKeyByThreadKey[threadKey] === projectKey &&
- !pendingVisits &&
- !pendingTitles
- ) {
- return;
- }
+ if (state.projectKeyByThreadKey[threadKey] === projectKey && !pendingVisits) return;
const nextPendingVisits = { ...state.pendingVisitsByThreadKey };
- const nextPendingTitles = { ...state.pendingTitlesByThreadKey };
delete nextPendingVisits[threadKey];
- delete nextPendingTitles[threadKey];
set({
projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey },
pendingVisitsByThreadKey: nextPendingVisits,
- pendingTitlesByThreadKey: nextPendingTitles,
});
for (const visit of pendingVisits ?? [])
get().recordVisit(projectKey, visit.url, visit.at, {
insertOrdered: true,
environmentHostname: visit.environmentHostname,
});
- for (const pendingTitle of pendingTitles ?? [])
- get().setTitleForUrl(
- projectKey,
- pendingTitle.url,
- pendingTitle.title,
- pendingTitle.environmentHostname,
- );
+ if (pendingVisits)
+ recordPreviewTitles(ref, readThreadPreviewState(ref), {
+ urls: pendingVisits.map((visit) => visit.url),
+ });
},
}),
{
@@ -302,7 +293,6 @@ export function mergeBrowserHistoryState(
...migrated,
projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey),
pendingVisitsByThreadKey: {},
- pendingTitlesByThreadKey: {},
};
}
@@ -341,28 +331,84 @@ export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: num
return;
}
state.recordVisit(projectKey, url, visitAt, { environmentHostname });
+ // A page can settle before the address bar records its visit.
+ recordPreviewTitles(ref, readThreadPreviewState(ref), { urls: [url] });
}
-export function setTitleForThreadUrl(
+/**
+ * Titles only update entries already in history, so they apply on settle and on
+ * visit. History is shared across a project's threads and settled sessions stay
+ * retained, so a visit only fills untitled entries: replaying this thread's
+ * older settle would clobber a newer title. Settles apply what just changed.
+ */
+function recordPreviewTitles(
ref: ScopedThreadRef,
- url: string,
- title: string,
- environmentHostname?: string | null,
+ preview: ThreadPreviewState,
+ only:
+ | { readonly urls: ReadonlyArray }
+ | { readonly changedSince: ThreadPreviewState["sessions"] },
): void {
- const threadKey = scopedThreadKey(ref);
const state = useBrowserHistoryStore.getState();
- const projectKey = state.projectKeyByThreadKey[threadKey];
- if (!projectKey) {
- useBrowserHistoryStore.setState({
- pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, {
- url,
- title,
- environmentHostname,
- }),
- });
- return;
+ const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
+ if (!projectKey || !state.byProjectKey[projectKey]?.length) return;
+ const connection = readPreparedConnection(ref.environmentId);
+ const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
+ const titleKey = (url: string) => {
+ const normalized = normalizeHistoryUrl(url);
+ return normalized ? titleLookupKey(normalized, environmentHostname) : null;
+ };
+ const titled = new Set(
+ "urls" in only
+ ? state.byProjectKey[projectKey]?.flatMap((entry) =>
+ entry.title ? [titleLookupKey(entry.url, environmentHostname)] : [],
+ )
+ : [],
+ );
+ const visitKeys =
+ "urls" in only
+ ? new Set(only.urls.map(titleKey).filter((key) => key !== null && !titled.has(key)))
+ : null;
+ const include = (session: PreviewSessionSnapshot, url: string) =>
+ "changedSince" in only
+ ? only.changedSince[session.tabId] !== session
+ : (visitKeys?.has(titleKey(url)) ?? false);
+ // Tabs can share a URL; applying oldest first lets the newest settle win.
+ const sessions = Object.values(preview.sessions).toSorted((a, b) =>
+ a.updatedAt.localeCompare(b.updatedAt),
+ );
+ for (const session of sessions) {
+ const status = session.navStatus;
+ if (status._tag !== "Success" || !status.title || !include(session, status.url)) continue;
+ state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname);
}
- state.setTitleForUrl(projectKey, url, title, environmentHostname);
+}
+
+/** Records settled page titles for every thread with previews, for the life of the page. */
+export function startPreviewTitleRecording(): () => void {
+ const followed = new Map void>();
+ const followNewThreads = (refs: ReadonlyMap) => {
+ for (const [threadKey, threadRef] of refs) {
+ if (followed.has(threadKey)) continue;
+ const atom = previewStateAtom(threadKey);
+ let previousSessions = appAtomRegistry.get(atom).sessions;
+ followed.set(
+ threadKey,
+ appAtomRegistry.subscribe(atom, (next) => {
+ if (next.sessions === previousSessions) return;
+ const changedSince = previousSessions;
+ previousSessions = next.sessions;
+ recordPreviewTitles(threadRef, next, { changedSince });
+ }),
+ );
+ }
+ };
+ const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, {
+ immediate: true,
+ });
+ return () => {
+ stopIndex();
+ for (const stop of followed.values()) stop();
+ };
}
export function removeUrlForThread(ref: ScopedThreadRef, url: string): void {
@@ -392,7 +438,6 @@ export function resetBrowserHistoryForTests(): void {
byProjectKey: {},
projectKeyByThreadKey: {},
pendingVisitsByThreadKey: {},
- pendingTitlesByThreadKey: {},
});
useBrowserHistoryStore.persist.clearStorage();
}
diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx
index 5df55e06d4f4a..1fb95a94f3fbd 100644
--- a/apps/web/src/components/preview/PreviewChromeRow.tsx
+++ b/apps/web/src/components/preview/PreviewChromeRow.tsx
@@ -11,8 +11,7 @@ import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
- useEffect,
- useRef,
+ type RefObject,
useState,
} from "react";
@@ -28,7 +27,8 @@ interface Props {
canGoForward: boolean;
refreshDisabled: boolean;
inputDisabled?: boolean | undefined;
- focusUrlNonce?: number | undefined;
+ /** Owned by the caller so its shortcut handler can focus the address bar directly. */
+ urlInputRef: RefObject;
onBack: () => void;
onForward: () => void;
onRefresh: () => void;
@@ -57,7 +57,7 @@ export function PreviewChromeRow({
canGoForward,
refreshDisabled,
inputDisabled,
- focusUrlNonce,
+ urlInputRef: inputRef,
onBack,
onForward,
onRefresh,
@@ -76,17 +76,9 @@ export function PreviewChromeRow({
trailingActions,
leadingActions,
}: Props) {
- const inputRef = useRef(null);
const [draft, setDraft] = useState(url);
const [inputFocused, setInputFocused] = useState(false);
- useEffect(() => {
- if (focusUrlNonce == null) return;
- const node = inputRef.current;
- if (!node) return;
- node.focus();
- }, [focusUrlNonce]);
-
const submit = (event?: FormEvent | KeyboardEvent) => {
event?.preventDefault();
const next = draft.trim();
diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx
index e62c5defa7974..c17655db964bc 100644
--- a/apps/web/src/components/preview/PreviewView.browser.test.tsx
+++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx
@@ -160,6 +160,7 @@ import { useComposerDraftStore } from "~/composerDraftStore";
import { useBrowserPointerStore } from "~/browser/browserPointerStore";
import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
+import { dispatchPreviewAction } from "./previewActionBus";
import { PreviewView } from "./PreviewView";
const TEST_THREAD_REF = {
@@ -271,4 +272,18 @@ describe("PreviewView", () => {
expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, sent);
expect(mocks.addImage).not.toHaveBeenCalled();
});
+
+ it("focuses the address bar each time the focus-url shortcut fires", async () => {
+ setup();
+ await using _screen = await render(view());
+ const address = page.getByRole("textbox");
+
+ act(() => dispatchPreviewAction("focus-url"));
+ await expect.element(address).toHaveFocus();
+
+ (address.element() as HTMLInputElement).blur();
+ await expect.element(address).not.toHaveFocus();
+ act(() => dispatchPreviewAction("focus-url"));
+ await expect.element(address).toHaveFocus();
+ });
});
diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx
index 65a0290ed3803..174c49c8cb60b 100644
--- a/apps/web/src/components/preview/PreviewView.tsx
+++ b/apps/web/src/components/preview/PreviewView.tsx
@@ -1,4 +1,4 @@
-import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream";
import {
@@ -10,7 +10,6 @@ import {
DEFAULT_BROWSER_PROFILE_ID,
FILL_PREVIEW_VIEWPORT,
type PreviewAnnotationPayload,
- type PreviewViewportSetting,
type ScopedThreadRef,
DEFAULT_PREVIEW_ZOOM_FACTOR,
PREVIEW_ZOOM_LEVELS,
@@ -28,7 +27,6 @@ import {
BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
recordVisitForThread,
removeUrlForThread,
- setTitleForThreadUrl,
useThreadRecentHistory,
} from "~/browserHistoryStore";
import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
@@ -66,10 +64,7 @@ import { showPreviewPopup } from "./showPreviewPopup";
import { PreviewChromeRow } from "./PreviewChromeRow";
import { PreviewEmptyState } from "./PreviewEmptyState";
import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
-import {
- commitBrowserViewportChange,
- subscribeBrowserViewportChange,
-} from "~/browser/browserViewportActions";
+import { commitBrowserViewportChange } from "~/browser/browserViewportActions";
import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
@@ -150,7 +145,7 @@ export function PreviewView({
visible,
onSendAnnotation,
}: Props) {
- const [focusUrlNonce, setFocusUrlNonce] = useState(undefined);
+ const urlInputRef = useRef(null);
const [pickActive, setPickActive] = useState(false);
const canSendAnnotation =
useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) &&
@@ -169,15 +164,11 @@ export function PreviewView({
const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
const addImage = useComposerDraftStore((store) => store.addImage);
const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId);
- const environmentHostname = environmentHttpBaseUrl
- ? new URL(environmentHttpBaseUrl).hostname
- : null;
const open = useAtomCommand(previewEnvironment.open);
const closePreview = useAtomCommand(previewEnvironment.close, "preview close");
const environmentLabel = useEnvironment(threadRef.environmentId)?.label ?? "the environment";
const primaryEnvironmentId = usePrimaryEnvironmentId();
const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId);
- const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
usePreviewSession(threadRef);
@@ -205,10 +196,10 @@ export function PreviewView({
const serverOwnsRendering = snapshot?.runtime === "server";
const serverSurfaceRef = useRef(null);
const [serverAspectRatioLocked, setServerAspectRatioLocked] = useState(false);
- const serverBodyRef = useRef(null);
const [serverToolbarWidth, setServerToolbarWidth] = useState(0);
const serverToolbarShown =
snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill";
+ const serverBodyRef = useRef(null);
useEffect(() => {
const element = serverBodyRef.current;
if (!element || !serverToolbarShown) return;
@@ -241,20 +232,6 @@ export function PreviewView({
runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
);
- const navUrl = navStatus._tag === "Success" ? navStatus.url : null;
- const navTitle = navStatus._tag === "Success" ? navStatus.title : null;
- const latestHistoryUrl = recentHistoryEntries[0]?.url;
- const { environmentId: threadEnvironmentId, threadId } = threadRef;
- useEffect(() => {
- if (!navUrl || !navTitle || !latestHistoryUrl) return;
- setTitleForThreadUrl(
- scopeThreadRef(threadEnvironmentId, threadId),
- navUrl,
- navTitle,
- environmentHostname,
- );
- }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadEnvironmentId, threadId]);
-
const navigateToResolvedUrl = async (resolvedUrl: string) => {
if (isServerTab && serverSurfaceRef.current) {
if (serverInputDisabled) return false;
@@ -355,37 +332,16 @@ export function PreviewView({
else if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
};
- const handleViewportChange = async (nextViewport: PreviewViewportSetting) => {
- if (!tabId) return;
- const result = await resize({
- environmentId: threadRef.environmentId,
- input: {
- threadId: threadRef.threadId,
- tabId,
- viewport: nextViewport,
- },
- });
- if (result._tag === "Failure") {
- const error = squashAtomCommandFailure(result);
- toastManager.add({
- type: "error",
- title: "Unable to resize browser viewport",
- description: error instanceof Error ? error.message : "An error occurred.",
- });
- throw error;
- }
- updatePreviewServerSnapshot(threadRef, result.value);
- };
-
const handleToggleDeviceToolbar = () => {
- if (!runtimeTabId) return;
+ if (!runtimeTabId || !tabId) return;
+ const target = { threadRef, tabId, runtimeTabId };
if (viewport._tag !== "fill") {
- void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
+ void commitBrowserViewportChange(target, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
return;
}
void commitBrowserViewportChange(
- runtimeTabId,
+ target,
browserResponsiveViewportForToggle({
defaults: browserDefaults,
panelRect,
@@ -394,14 +350,6 @@ export function PreviewView({
).catch(() => undefined);
};
- const onViewportChange = useEffectEvent(handleViewportChange);
- useEffect(() => {
- if (!runtimeTabId) return;
- return subscribeBrowserViewportChange(runtimeTabId, (nextViewport) =>
- onViewportChange(nextViewport),
- );
- }, [runtimeTabId]);
-
const handleBack = () => {
if (isServerTab) serverSurfaceRef.current?.history(-1);
else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
@@ -875,7 +823,7 @@ export function PreviewView({
handleRefresh();
return;
case "focus-url":
- setFocusUrlNonce((value) => (value ?? 0) + 1);
+ urlInputRef.current?.focus();
return;
case "zoom-in":
handleZoomIn();
@@ -908,7 +856,7 @@ export function PreviewView({
canGoForward={canGoForward && !serverInputDisabled}
refreshDisabled={refreshDisabled || serverInputDisabled}
inputDisabled={serverInputDisabled}
- focusUrlNonce={focusUrlNonce}
+ urlInputRef={urlInputRef}
onBack={handleBack}
onForward={handleForward}
onRefresh={handleRefresh}
@@ -978,7 +926,12 @@ export function PreviewView({
width={serverToolbarWidth}
aspectRatio={serverAspectRatioLocked ? viewport.width / viewport.height : null}
onAspectRatioChange={(ratio) => setServerAspectRatioLocked(ratio !== null)}
- onChange={(setting) => commitBrowserViewportChange(runtimeTabId, setting)}
+ onChange={(setting) =>
+ commitBrowserViewportChange(
+ { threadRef, tabId: snapshot.tabId, runtimeTabId },
+ setting,
+ )
+ }
/>
) : null}
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index c349b692c41e0..b65ab6c189dc0 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -113,7 +113,7 @@ export function subscribeClientSettings(listener: () => void): () => void {
};
}
-function getClientSettingsHydratedSnapshot(): boolean {
+export function getClientSettingsHydratedSnapshot(): boolean {
return clientSettingsHydrationStatus === "ready";
}
@@ -121,7 +121,7 @@ function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStat
return clientSettingsHydrationStatus;
}
-function subscribeClientSettingsHydration(listener: () => void): () => void {
+export function subscribeClientSettingsHydration(listener: () => void): () => void {
clientSettingsHydrationListeners.add(listener);
void hydrateClientSettings().catch(() => undefined);
return () => {
diff --git a/apps/web/src/state/previewStream.ts b/apps/web/src/state/previewStream.ts
index 3b2cb4629fbb2..52585ee05ccb5 100644
--- a/apps/web/src/state/previewStream.ts
+++ b/apps/web/src/state/previewStream.ts
@@ -13,6 +13,8 @@ import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentSession } from "./session";
+const ACCESS_RETRY_INTERVAL_MS = 10_000;
+
const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
connectionAtomRuntime
.atom((get) => {
@@ -25,8 +27,29 @@ const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
.pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`preview-stream-access:${environmentId}`)),
);
-export function usePreviewStreamAccess(environmentId: EnvironmentId): DeviceHubAccess | null {
- const result = useAtomValue(previewStreamAccessAtom(environmentId));
+// Only a surface the user is looking at retries a failed lookup; one-shot readers such as
+// picture-in-picture see the failure at once.
+const retryingPreviewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get) => {
+ const access = previewStreamAccessAtom(environmentId);
+ const result = get(access);
+ if (AsyncResult.isFailure(result)) {
+ const timer = setTimeout(() => get.refresh(access), ACCESS_RETRY_INTERVAL_MS);
+ get.addFinalizer(() => clearTimeout(timer));
+ }
+ return result;
+ }),
+);
+
+export function usePreviewStreamAccess(
+ environmentId: EnvironmentId,
+ options?: { readonly retry: boolean },
+): DeviceHubAccess | null {
+ const result = useAtomValue(
+ options?.retry
+ ? retryingPreviewStreamAccessAtom(environmentId)
+ : previewStreamAccessAtom(environmentId),
+ );
return AsyncResult.isSuccess(result) ? result.value : null;
}