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: 2 additions & 0 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"

import { applyAppearanceContrast } from "./appearanceContrast";
import { applyAppearanceFontVariables } from "./appearanceFonts";
import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews";
import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding";
import { resolveServerBackedAppDisplayName } from "./branding.logic";
import { isElectron } from "./env";
Expand Down Expand Up @@ -101,6 +102,7 @@ export function startAppServices(): AppRouter {

startClientAppearance();
startDocumentTitle();
startAgentOpenedPreviews();

return router;
}
32 changes: 0 additions & 32 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,6 @@ export default function ChatView(props: ChatViewProps) {
);
const activePreviewState = useThreadPreviewState(activeThreadRef);
const activePreviewServerEpoch = activePreviewState.serverEpoch;
const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded;
const resolvePreviewRuntimeTabId = activeThreadRef
? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId)
: undefined;
Expand Down Expand Up @@ -988,34 +987,6 @@ export default function ChatView(props: ChatViewProps) {
),
);

useEffect(() => {
if (!activeThreadRef || !previewSessionsReady) return;
const hiddenTabIds = new Set(
Object.values(activePreviewState.sessions)
.filter((session) => session.runtime === "server" && session.reveal === false)
.map((session) => session.tabId),
);
useRightPanelStore
.getState()
.reconcileBrowserSurfaces(
activeThreadRef,
Object.keys(activePreviewState.sessions),
hiddenTabIds,
);
}, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]);

useEffect(() => {
const source = activePreviewMiniPlayer?.source;
if (
activeThreadRef &&
previewSessionsReady &&
source?.kind === "browser" &&
!activePreviewState.sessions[source.tabId]
) {
usePreviewMiniPlayerStore.getState().close(activeThreadRef);
}
}, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]);

const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime);
const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId);
const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime);
Expand Down Expand Up @@ -1900,9 +1871,6 @@ export default function ChatView(props: ChatViewProps) {
threadRef: activeThreadRef,
deviceState,
deviceStateLoaded,
previewState: activePreviewState,
serverBrowserAvailable: activeEnvironmentServerBrowser,
panelSurfaces: rightPanelState.surfaces,
miniPlayerSource: activePreviewMiniPlayer?.source ?? null,
sheetLayout: shouldUsePlanSidebarSheet,
});
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/components/preview/agentOpenedPreviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore";
import { __testing as previewStateTesting } from "../../previewStateStore";
import { useRightPanelStore } from "../../rightPanelStore";
import { followPreviewSessions, revealServerTabs } from "./agentOpenedPreviews";

vi.mock("../../state/entities", () => ({
readEnvironmentSupportsServerBrowser: () => true,
}));

const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
const threadKey = scopedThreadKey(ref);

function setup() {
usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
useRightPanelStore.setState({ byThreadKey: {} });
}

const miniPlayerSource = () => usePreviewMiniPlayerStore.getState().byThreadKey[threadKey]?.source;

describe("revealServerTabs", () => {
const tab = (tabId: string, force?: boolean): PreviewSessionSnapshot => ({
threadId: "thread-1",
tabId,
navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
canGoBack: false,
canGoForward: false,
updatedAt: "2026-01-01T00:00:00.000Z",
runtime: "server",
reveal: true,
...(force === undefined ? {} : { revealRequest: { id: "r1", force } }),
});
const floatingTabId = () => {
const source = miniPlayerSource();
return source?.kind === "browser" ? source.tabId : null;
};

it("floats revealed tabs, and only forced ones when floating previews are off", () => {
setup();
revealServerTabs(ref, [tab("tab_plain")], { autoShowFloatingPreview: false });
expect(floatingTabId()).toBeNull();
revealServerTabs(ref, [tab("tab_forced", true)], { autoShowFloatingPreview: false });
expect(floatingTabId()).toBe("tab_forced");
});

it("focuses a tab already in the panel instead of floating it when forced", () => {
setup();
useRightPanelStore.getState().openBrowser(ref, "tab_a");
const browserSurfaceId = useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId;
useRightPanelStore.getState().open(ref, "diff");

revealServerTabs(ref, [tab("tab_a", false)], { autoShowFloatingPreview: true });
expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).not.toBe(
browserSurfaceId,
);
revealServerTabs(ref, [tab("tab_a", true)], { autoShowFloatingPreview: true });
expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe(
browserSurfaceId,
);
expect(floatingTabId()).toBeNull();
});
});

describe("followPreviewSessions", () => {
const snapshot = (tabId: string, reveal = true): PreviewSessionSnapshot => ({
threadId: "thread-1",
tabId,
navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
canGoBack: false,
canGoForward: false,
updatedAt: "2026-01-01T00:00:00.000Z",
runtime: "server",
reveal,
});
const previewState = (sessions: ReadonlyArray<PreviewSessionSnapshot>, listLoaded = true) => ({
...previewStateTesting.EMPTY_THREAD_PREVIEW_STATE,
listLoaded,
sessions: Object.fromEntries(sessions.map((entry) => [entry.tabId, entry])),
});
const browserTabs = () =>
useRightPanelStore
.getState()
.byThreadKey[threadKey]?.surfaces.filter((surface) => surface.kind === "preview")
.map((surface) => surface.id);

it("adds visible sessions as tabs and drops tabs whose session ended", () => {
setup();
useRightPanelStore.getState().openBrowser(ref, "tab_gone");
followPreviewSessions(ref, previewState([snapshot("tab_a"), snapshot("tab_hidden", false)]));
expect(browserTabs()).toEqual(["browser:tab_a"]);
});

it("waits for the first server list before treating missing sessions as closed", () => {
setup();
useRightPanelStore.getState().openBrowser(ref, "tab_a");
usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource("tab_a"));
followPreviewSessions(ref, previewState([], false));
expect(browserTabs()).toEqual(["browser:tab_a"]);
expect(miniPlayerSource()).toMatchObject({ kind: "browser", tabId: "tab_a" });

followPreviewSessions(ref, previewState([]));
expect(browserTabs()).toEqual([]);
expect(miniPlayerSource()).toBeUndefined();
});
});
74 changes: 74 additions & 0 deletions apps/web/src/components/preview/agentOpenedPreviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";

import { getClientSettings } from "../../hooks/useSettings";
import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore";
import {
onPreviewSessionsChanged,
onServerTabsRevealed,
type ThreadPreviewState,
} from "../../previewStateStore";
import { useRightPanelStore } from "../../rightPanelStore";
import { readEnvironmentSupportsServerBrowser } from "../../state/entities";

/**
* Shows browsers an agent or another client revealed for a thread: floating
* over chat, or focusing an existing panel tab when the reveal is forced.
*/
export function revealServerTabs(
threadRef: ScopedThreadRef,
sessions: ReadonlyArray<PreviewSessionSnapshot>,
options: { readonly autoShowFloatingPreview: boolean },
): void {
if (!readEnvironmentSupportsServerBrowser(threadRef.environmentId)) return;
for (const session of sessions) {
const force = session.revealRequest?.force === true;
if (!options.autoShowFloatingPreview && !force) continue;
const surface = useRightPanelStore
.getState()
.byThreadKey[scopedThreadKey(threadRef)]?.surfaces.find(
(surface) => surface.kind === "preview" && surface.resourceId === session.tabId,
);
if (surface && force) {
useRightPanelStore.getState().activateSurface(threadRef, surface.id);
} else if (!surface) {
usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(session.tabId));
}
}
}

/**
* Keeps a thread's browser panel tabs and floating browser in step with its
* preview sessions. Server-hosted sessions are only complete once the first
* list has loaded; before that, a missing session is not a closed one.
*/
export function followPreviewSessions(threadRef: ScopedThreadRef, state: ThreadPreviewState): void {
if (readEnvironmentSupportsServerBrowser(threadRef.environmentId) && !state.listLoaded) return;
const sessions = Object.values(state.sessions);
useRightPanelStore.getState().reconcileBrowserSurfaces(
threadRef,
sessions.map((session) => session.tabId),
new Set(
sessions
.filter((session) => session.runtime === "server" && session.reveal === false)
.map((session) => session.tabId),
),
);
const miniPlayer = usePreviewMiniPlayerStore.getState();
const source = miniPlayer.byThreadKey[scopedThreadKey(threadRef)]?.source;
if (source?.kind === "browser" && !state.sessions[source.tabId]) miniPlayer.close(threadRef);
}

let started = false;

/** 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,
}),
);
}
58 changes: 2 additions & 56 deletions apps/web/src/components/preview/useAgentOpenedPreviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { useEffect, useRef } from "react";
import { useClientSettings } from "../../hooks/useSettings";
import {
browserMiniPlayerSource,
usePreviewMiniPlayerStore,
type PreviewMiniPlayerState,
} from "../../previewMiniPlayerStore";
import type { ThreadPreviewState } from "../../previewStateStore";
import { useRightPanelStore, type RightPanelSurface } from "../../rightPanelStore";
import { useRightPanelStore } from "../../rightPanelStore";

const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
settings.browserAutoShowFloatingPreview;
Expand All @@ -17,23 +15,11 @@ export function useAgentOpenedPreviews(input: {
readonly threadRef: ScopedThreadRef | null;
readonly deviceState: DeviceServiceState;
readonly deviceStateLoaded: boolean;
readonly previewState: ThreadPreviewState;
readonly serverBrowserAvailable: boolean;
readonly panelSurfaces: ReadonlyArray<RightPanelSurface>;
readonly miniPlayerSource: PreviewMiniPlayerState["source"] | null;
// Sheet layouts show neither floating previews nor auto-opened device tabs.
readonly sheetLayout: boolean;
}) {
const {
threadRef,
deviceState,
deviceStateLoaded,
previewState,
serverBrowserAvailable,
panelSurfaces,
miniPlayerSource,
sheetLayout,
} = input;
const { threadRef, deviceState, deviceStateLoaded, miniPlayerSource, sheetLayout } = input;
const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview);
// Baseline sessions to preserve dismissals on reload; missing device summaries must remain eligible to open
// later.
Expand Down Expand Up @@ -88,46 +74,6 @@ export function useAgentOpenedPreviews(input: {
deviceState.sessions,
deviceState.devices,
]);
// Baseline loaded tabs so reloads never reopen previews the user dismissed.
const previousServerPreviewTabs = useRef(new Map<string, Map<string, string | undefined>>());
useEffect(() => {
if (!threadRef || !serverBrowserAvailable || !previewState.listLoaded) return;
const threadKey = scopedThreadKey(threadRef);
const serverSessions = Object.values(previewState.sessions).filter(
(session) => session.runtime === "server",
);
const previous = previousServerPreviewTabs.current.get(threadKey);
previousServerPreviewTabs.current.set(
threadKey,
new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])),
);
if (!previous) return;
for (const session of serverSessions) {
const requested = session.revealRequest;
const fresh = requested
? previous.get(session.tabId) !== requested.id
: !previous.has(session.tabId);
if (!fresh || session.reveal !== true) continue;
if (!autoShowFloatingPreview && requested?.force !== true) continue;
const surface = panelSurfaces.find(
(surface) => surface.kind === "preview" && surface.resourceId === session.tabId,
);
if (surface && requested?.force === true) {
useRightPanelStore.getState().activateSurface(threadRef, surface.id);
} else if (!surface) {
usePreviewMiniPlayerStore
.getState()
.open(threadRef, browserMiniPlayerSource(session.tabId));
}
}
}, [
serverBrowserAvailable,
previewState.listLoaded,
previewState.sessions,
threadRef,
autoShowFloatingPreview,
panelSurfaces,
]);
useEffect(() => {
if (!threadRef || !deviceStateLoaded) return;
const source = miniPlayerSource;
Expand Down
Loading
Loading