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
140 changes: 138 additions & 2 deletions apps/web/src/components/preview/agentOpenedPreviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3to
import { describe, expect, it, vi } from "vite-plus/test";

import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore";
import { __testing as previewStateTesting } from "../../previewStateStore";
import {
__testing as previewStateTesting,
applyPreviewServerEvent,
applyPreviewServerSnapshot,
beginPreviewSessionClose,
cancelPreviewSessionClose,
reconcilePreviewServerSessions,
resetPreviewStateForTests,
} from "../../previewStateStore";
import { useRightPanelStore } from "../../rightPanelStore";
import { followPreviewSessions, revealServerTabs } from "./agentOpenedPreviews";
import {
followPreviewSessions,
revealServerTabs,
startAgentOpenedPreviews,
} from "./agentOpenedPreviews";

vi.mock("../../state/entities", () => ({
readEnvironmentSupportsServerBrowser: () => true,
Expand All @@ -15,6 +27,7 @@ const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
const threadKey = scopedThreadKey(ref);

function setup() {
resetPreviewStateForTests();
usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
useRightPanelStore.setState({ byThreadKey: {} });
}
Expand Down Expand Up @@ -106,3 +119,126 @@ describe("followPreviewSessions", () => {
expect(miniPlayerSource()).toBeUndefined();
});
});

describe("startAgentOpenedPreviews", () => {
const serverEpoch = "server-a";
const serverTab = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({
threadId: "thread-1",
tabId: "tab_a",
navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
canGoBack: false,
canGoForward: false,
updatedAt: "2026-01-01T00:00:00.000Z",
runtime: "server",
reveal: true,
...overrides,
});
const following = () => {
setup();
return { [Symbol.dispose]: startAgentOpenedPreviews() };
};
const navigated = (revision: number, snapshot: PreviewSessionSnapshot) =>
applyPreviewServerEvent(ref, {
type: "navigated",
threadId: "thread-1",
tabId: snapshot.tabId,
createdAt: snapshot.updatedAt,
snapshot,
serverEpoch,
revision,
});
const floatingTabId = () => {
const source = miniPlayerSource();
return source?.kind === "browser" ? source.tabId : null;
};
const browserTabs = (threadKeyToRead = threadKey) =>
useRightPanelStore
.getState()
.byThreadKey[threadKeyToRead]?.surfaces.filter((surface) => surface.kind === "preview")
.map((surface) => surface.id);

it("treats the first list as a baseline and floats tabs opened later", () => {
using _following = following();
reconcilePreviewServerSessions(ref, {
sessions: [serverTab({ tabId: "tab_restored" })],
serverEpoch,
revision: 1,
});
expect(floatingTabId()).toBeNull();
expect(browserTabs()).toEqual(["browser:tab_restored"]);

const opened = serverTab({ tabId: "tab_agent" });
applyPreviewServerEvent(ref, {
type: "opened",
threadId: "thread-1",
tabId: opened.tabId,
createdAt: opened.updatedAt,
snapshot: opened,
serverEpoch,
revision: 2,
});
expect(floatingTabId()).toBe("tab_agent");
});

it("reveals an existing tab only for a new reveal request", () => {
using _following = following();
const tab = serverTab({ revealRequest: { id: "r1", force: false } });
reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 });

navigated(2, { ...tab, updatedAt: "2026-01-01T00:00:01.000Z" });
expect(floatingTabId()).toBeNull();

navigated(3, {
...tab,
revealRequest: { id: "r2", force: true },
updatedAt: "2026-01-01T00:00:02.000Z",
});
expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe(
"browser:tab_a",
);
});

it("never reveals tabs this client opened, desktop tabs, or a tab whose close failed", () => {
using _following = following();
const tab = serverTab({ tabId: "tab_closing" });
reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 });
applyPreviewServerSnapshot(ref, serverTab({ tabId: "tab_own", reveal: undefined }));
applyPreviewServerSnapshot(ref, { ...serverTab({ tabId: "tab_desktop" }), runtime: "desktop" });
beginPreviewSessionClose(ref, tab.tabId);
cancelPreviewSessionClose(ref, tab, tab.tabId);
expect(floatingTabId()).toBeNull();
});

it("follows background threads and closes the floating browser when its session ends", () => {
using _following = following();
const other = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-2"));
reconcilePreviewServerSessions(other, {
sessions: [serverTab({ threadId: "thread-2", tabId: "tab_b" })],
serverEpoch,
revision: 1,
});
expect(browserTabs(scopedThreadKey(other))).toEqual(["browser:tab_b"]);

reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
const opened = serverTab();
applyPreviewServerEvent(ref, {
type: "opened",
threadId: "thread-1",
tabId: opened.tabId,
createdAt: opened.updatedAt,
snapshot: opened,
serverEpoch,
revision: 2,
});
expect(floatingTabId()).toBe("tab_a");
applyPreviewServerEvent(ref, {
type: "closed",
threadId: "thread-1",
tabId: opened.tabId,
createdAt: opened.updatedAt,
serverEpoch,
revision: 3,
});
expect(floatingTabId()).toBeNull();
});
});
67 changes: 55 additions & 12 deletions apps/web/src/components/preview/agentOpenedPreviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts
import { getClientSettings } from "../../hooks/useSettings";
import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore";
import {
onPreviewSessionsChanged,
onServerTabsRevealed,
previewStateAtom,
previewThreadRefsAtom,
type ThreadPreviewState,
} from "../../previewStateStore";
import { useRightPanelStore } from "../../rightPanelStore";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { readEnvironmentSupportsServerBrowser } from "../../state/entities";

/**
Expand Down Expand Up @@ -59,16 +60,58 @@ export function followPreviewSessions(threadRef: ScopedThreadRef, state: ThreadP
if (source?.kind === "browser" && !state.sessions[source.tabId]) miniPlayer.close(threadRef);
}

let started = false;
/**
* Server tabs that are new, or carry a new reveal request, since `previous`.
* Nothing is fresh until the first list has loaded: that list is the baseline,
* so a reload never reopens previews the user dismissed. A tab this client was
* closing comes back without a reveal if the close fails.
*/
function freshlyRevealedServerTabs(
previous: ThreadPreviewState,
next: ThreadPreviewState,
): ReadonlyArray<PreviewSessionSnapshot> {
if (!previous.listLoaded || previous.sessions === next.sessions) return [];
return Object.values(next.sessions).filter((session) => {
if (session.runtime !== "server" || session.reveal !== true) return false;
if (previous.suppressedTabIds.has(session.tabId)) return false;
const before = previous.sessions[session.tabId];
const requested = session.revealRequest;
return requested ? before?.revealRequest?.id !== requested.id : before === undefined;
});
}

function followThread(threadKey: string, threadRef: ScopedThreadRef): () => void {
const atom = previewStateAtom(threadKey);
let previous = appAtomRegistry.get(atom);
return appAtomRegistry.subscribe(atom, (next) => {
const before = previous;
previous = next;
// Reveals run first so a revealed tab floats before it gains a panel tab.
const revealed = freshlyRevealedServerTabs(before, next);
if (revealed.length > 0) {
revealServerTabs(threadRef, revealed, {
autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview,
});
}
if (before.sessions !== next.sessions || before.listLoaded !== next.listLoaded) {
followPreviewSessions(threadRef, next);
}
});
}

/** Follows every thread's browser previews for the life of the page, not just the open thread's. */
export function startAgentOpenedPreviews(): void {
if (started) return;
started = true;
onPreviewSessionsChanged(followPreviewSessions);
onServerTabsRevealed((threadRef, sessions) =>
revealServerTabs(threadRef, sessions, {
autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview,
}),
);
export function startAgentOpenedPreviews(): () => void {
const followed = new Map<string, () => void>();
const followNewThreads = (refs: ReadonlyMap<string, ScopedThreadRef>) => {
for (const [threadKey, threadRef] of refs) {
if (!followed.has(threadKey)) followed.set(threadKey, followThread(threadKey, threadRef));
}
};
const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, {
immediate: true,
});
return () => {
stopIndex();
for (const stop of followed.values()) stop();
};
}
74 changes: 0 additions & 74 deletions apps/web/src/previewStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
reconcilePreviewServerSessions,
resetPreviewStateForTests,
setActivePreviewTab,
onServerTabsRevealed,
} from "./previewStateStore";
import { appAtomRegistry } from "./rpc/atomRegistry";

Expand Down Expand Up @@ -419,76 +418,3 @@ describe("previewStateStore (single-tab)", () => {
});
});
});

describe("previewStateStore server reveals", () => {
const collectReveals = () => {
const tabIds: string[] = [];
const stop = onServerTabsRevealed((_ref, sessions) => {
tabIds.push(...sessions.map((session) => session.tabId));
});
return { tabIds, [Symbol.dispose]: stop };
};
const serverTab = (overrides: Partial<PreviewSessionSnapshot> = {}) =>
makeSnapshot({ runtime: "server", reveal: true, ...overrides });

it("treats the first list as a baseline and reveals tabs opened later", () => {
using reveals = collectReveals();
const revealed = reveals.tabIds;
reconcilePreviewServerSessions(ref, {
sessions: [serverTab({ tabId: "tab_restored" })],
serverEpoch,
revision: 1,
});
expect(revealed).toEqual([]);

const opened = serverTab({ tabId: "tab_agent" });
applyPreviewServerEvent(ref, {
type: "opened",
threadId: "thread-1",
tabId: opened.tabId,
createdAt: opened.updatedAt,
snapshot: opened,
});
expect(revealed).toEqual(["tab_agent"]);
});

it("reveals an existing tab only for a new reveal request", () => {
using reveals = collectReveals();
const revealed = reveals.tabIds;
const tab = serverTab({ revealRequest: { id: "r1", force: false } });
reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 });

const navigated = { ...tab, updatedAt: "2026-01-01T00:00:01.000Z" };
applyPreviewServerEvent(ref, {
type: "navigated",
threadId: "thread-1",
tabId: tab.tabId,
createdAt: navigated.updatedAt,
snapshot: navigated,
});
expect(revealed).toEqual([]);

const requested = {
...navigated,
revealRequest: { id: "r2", force: true },
updatedAt: "2026-01-01T00:00:02.000Z",
};
applyPreviewServerEvent(ref, {
type: "navigated",
threadId: "thread-1",
tabId: tab.tabId,
createdAt: requested.updatedAt,
snapshot: requested,
});
expect(revealed).toEqual([tab.tabId]);
});

it("never reveals tabs this client opened or desktop tabs", () => {
using reveals = collectReveals();
const revealed = reveals.tabIds;
reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
applyPreviewServerSnapshot(ref, serverTab({ tabId: "tab_own", reveal: undefined }));
applyPreviewServerSnapshot(ref, makeSnapshot({ tabId: "tab_desktop", reveal: true }));
expect(revealed).toEqual([]);
});
});
Loading
Loading