Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,7 +26,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
<AppAtomRegistryProvider>
<RouterProvider router={router} />
<ElectronBrowserHost />
<BrowserProfileReporter />
<QuitHoldOverlay />
</AppAtomRegistryProvider>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -144,6 +146,8 @@ export function startAppServices(): AppRouter {
startModelPreferencesMigration();
startAgentOpenedPreviews();
if (isElectron) startElectronBrowserHost();
startPreviewTitleRecording();
startBrowserProfileReporting();
startThreadOverlayRetention(router);
startMainAppLocationTracking(router);
startBackgroundThreadAnnouncements(appAtomRegistry, router);
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/browser/BrowserProfileReporter.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
106 changes: 106 additions & 0 deletions apps/web/src/browser/BrowserProfileReporter.ts
Original file line number Diff line number Diff line change
@@ -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<EnvironmentId, EnvironmentServerConfig>();
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<EnvironmentId, EnvironmentServerConfig>,
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
);
}
42 changes: 0 additions & 42 deletions apps/web/src/browser/BrowserProfileReporter.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export function HostedBrowserWebview(props: {
handleResizePointerDown,
layout: viewportLayout,
} = useBrowserViewportResize({
tabId: runtimeTabId,
target: { threadRef, tabId, runtimeTabId },
viewport,
zoomFactor: layoutZoomFactor,
containerSize,
Expand Down
9 changes: 1 addition & 8 deletions apps/web/src/browser/ServerBrowserSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -161,7 +160,6 @@ export function ServerBrowserSurface(props: {
pdfUrl = null,
ref,
} = props;
const access = usePreviewStreamAccess(environmentId);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const keySentRef = useRef(false);
Expand Down Expand Up @@ -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<PreviewStreamHostSetup | null>(null);
const pendingMoveRef = useRef<MouseInput | null>(null);
const pendingWheelRef = useRef<WheelInput | null>(null);
Expand Down Expand Up @@ -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;
Expand Down
76 changes: 59 additions & 17 deletions apps/web/src/browser/browserViewportActions.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -16,31 +37,52 @@ describe("browserViewportActions", () => {
markFirstStarted = resolve;
});
const calls: Array<number> = [];
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);
});
});
Loading
Loading