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; }