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 @@ -4,6 +4,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"

import { applyAppearanceContrast } from "./appearanceContrast";
import { applyAppearanceFontVariables } from "./appearanceFonts";
import { startElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention";
import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer";
import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog";
Expand Down Expand Up @@ -141,6 +142,7 @@ export function startAppServices(): AppRouter {
startDefaultThemeAdoption();
startModelPreferencesMigration();
startAgentOpenedPreviews();
if (isElectron) startElectronBrowserHost();
startThreadOverlayRetention(router);
startMainAppLocationTracking(router);
startBackgroundThreadAnnouncements(appAtomRegistry, router);
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/browser/BrowserSurfaceSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,11 @@ export function BrowserSurfaceSlot(props: {
className,
fitSourceContent = false,
} = props;
const elementRef = useRef<HTMLDivElement | null>(null);
const presentationRef = useRef({ visible, cornerRadius, zIndex });
const updateRef = useRef<(() => void) | null>(null);

useLayoutEffect(() => {
const element = elementRef.current;
if (!element) return;
// The lease lives as long as this element for one tab, so the ref callback owns it.
const attachSurface = (element: HTMLDivElement) => {
let lease = acquireBrowserSurface(tabId, fitSourceContent);
const update = () => {
const rect = element.getBoundingClientRect();
Expand Down Expand Up @@ -77,7 +75,7 @@ export function BrowserSurfaceSlot(props: {
if (updateRef.current === update) updateRef.current = null;
lease.release();
};
}, [fitSourceContent, tabId]);
};

useLayoutEffect(() => {
presentationRef.current = { visible, cornerRadius, zIndex };
Expand All @@ -90,5 +88,5 @@ export function BrowserSurfaceSlot(props: {
zIndex,
]);

return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />;
return <div ref={attachSurface} className={className} data-browser-surface-slot={tabId} />;
}
142 changes: 80 additions & 62 deletions apps/web/src/browser/ElectronBrowserHost.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
import {
AuthPreviewOperateScope,
type DesktopPreviewBridge,
type DesktopPreviewOpenLinkEvent,
type EnvironmentId,
FILL_PREVIEW_VIEWPORT,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { type ComponentProps, useEffect, useEffectEvent, useState } from "react";
import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
import { type ComponentProps, useState } from "react";

import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";

import { isElectron } from "~/env";
import { useClientSettingsHydrated } from "~/hooks/useSettings";
import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession";
import { useActivePreviewSessions } from "~/previewStateStore";
import {
readActivePreviewSessions,
type ThreadPreviewState,
useActivePreviewSessions,
} from "~/previewStateStore";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { previewEnvironment } from "~/state/preview";
import { useEnvironmentScope } from "~/state/session";
import { useAtomCommand } from "~/state/use-atom-command";

import { readPreviewAnnotationTheme } from "./annotationTheme";
import { useBrowserDefaults } from "./browserDefaults";
Expand All @@ -25,10 +32,11 @@ import { openUrlInPreview } from "./openFileInPreview";
import { rendersServerTabNatively } from "./previewRuntime";
import { previewRuntimeTabId } from "./previewRuntimeTabId";

export function ElectronBrowserHost() {
const previewByThreadKey = useActivePreviewSessions();
const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
useDesktopHostedPreviewSessions(primaryEnvironmentId);
/** Every session this window renders as a hosted webview, in a fixed (non-display) order. */
function hostedBrowserSessions(
previewByThreadKey: Record<string, ThreadPreviewState>,
primaryEnvironmentId: EnvironmentId | null,
) {
const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
const threadRef = parseScopedThreadKey(threadKey);
return threadRef
Expand All @@ -50,68 +58,78 @@ export function ElectronBrowserHost() {
});
// Electron destroys a moved webview's guest, so the render order must not follow the server's
// updatedAt order. Any fixed order works; this one is not a display order.
const sessions = activeSessions.toSorted((left, right) =>
return activeSessions.toSorted((left, right) =>
left.runtimeTabId < right.runtimeTabId ? -1 : left.runtimeTabId > right.runtimeTabId ? 1 : 0,
);
}

useEffect(() => {
const preview = window.desktopBridge?.preview;
if (!preview) return;

let lastSerializedTheme = "";
const syncTheme = () => {
const theme = readPreviewAnnotationTheme();
const serializedTheme = JSON.stringify(theme);
if (serializedTheme === lastSerializedTheme) return;
lastSerializedTheme = serializedTheme;
void preview.setAnnotationTheme(theme).catch(() => {
lastSerializedTheme = "";
});
};
const frameId = window.requestAnimationFrame(syncTheme);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
const headObserver = new MutationObserver(syncTheme);
headObserver.observe(document.head, {
childList: true,
subtree: true,
characterData: true,
function startAnnotationThemeSync(preview: DesktopPreviewBridge): () => void {
let lastSerializedTheme = "";
const syncTheme = () => {
const theme = readPreviewAnnotationTheme();
const serializedTheme = JSON.stringify(theme);
if (serializedTheme === lastSerializedTheme) return;
lastSerializedTheme = serializedTheme;
void preview.setAnnotationTheme(theme).catch(() => {
lastSerializedTheme = "";
});
return () => {
window.cancelAnimationFrame(frameId);
observer.disconnect();
headObserver.disconnect();
};
}, []);
};
// App services start before the first paint, when the theme styles may not be applied yet.
const frameId = window.requestAnimationFrame(syncTheme);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
const headObserver = new MutationObserver(syncTheme);
headObserver.observe(document.head, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
window.cancelAnimationFrame(frameId);
observer.disconnect();
headObserver.disconnect();
};
}

useEffect(() => {
const preview = window.desktopBridge?.preview;
if (!preview) return;
return preview.onPointerEvent((event) => {
function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEvent): void {
const source = hostedBrowserSessions(
readActivePreviewSessions(),
appAtomRegistry.get(primaryEnvironmentIdAtom),
).find((session) => session.runtimeTabId === tabId);
if (!source) return;
void openUrlInPreview({
threadRef: source.threadRef,
url,
openPreview: (input) => runAtomCommand(appAtomRegistry, previewEnvironment.open, input),
profileId: source.snapshot.profileId,
background,
});
}

/** App-lifetime listeners for the desktop preview bridge: annotation theme, agent pointer, links. */
export function startElectronBrowserHost(): () => void {
const preview = window.desktopBridge?.preview;
if (!preview) return () => {};
const disposers = [
startAnnotationThemeSync(preview),
preview.onPointerEvent((event) => {
useBrowserPointerStore.getState().apply(event);
});
}, []);
}),
preview.onOpenLink(openLinkInPreview),
];
return () => {
for (const dispose of disposers) dispose();
};
}

const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true });
const onOpenLink = useEffectEvent(({ tabId, url, background }: DesktopPreviewOpenLinkEvent) => {
const source = sessions.find((session) => session.runtimeTabId === tabId);
if (!source) return;
void openUrlInPreview({
threadRef: source.threadRef,
url,
openPreview,
profileId: source.snapshot.profileId,
background,
});
});
useEffect(() => {
const preview = window.desktopBridge?.preview;
if (!preview) return;
return preview.onOpenLink(onOpenLink);
}, []);
export function ElectronBrowserHost() {
const previewByThreadKey = useActivePreviewSessions();
const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
useDesktopHostedPreviewSessions(primaryEnvironmentId);
const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId);

if (!isElectron) return null;
return (
Expand Down
48 changes: 20 additions & 28 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -981,10 +981,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true);
}, [props.open]);

const updateTabScrollState = () => {
const viewport = tabScrollViewport(tabListRef.current);
if (!viewport) return;

const updateTabScrollState = (viewport: HTMLDivElement) => {
const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE;
const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE;
const canScrollRight =
Expand Down Expand Up @@ -1183,34 +1180,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
setRenamingDevice(null);
});

// A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip.
useEffect(() => {
if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return;
const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']");
activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [props.activeSurfaceId, tabScrollState.hasOverflow]);

useEffect(() => {
const viewport = tabScrollViewport(tabListRef.current);
if (!viewport) return;

const content = viewport.firstElementChild;
const stopObserving = observeResize(
content ? [viewport, content] : viewport,
updateTabScrollState,
);
viewport.addEventListener("scroll", updateTabScrollState, { passive: true });
updateTabScrollState();

return () => {
stopObserving();
viewport.removeEventListener("scroll", updateTabScrollState);
};
}, [updateTabScrollState]);

useEffect(() => {
const viewport = tabScrollViewport(tabListRef.current);
const attachTabList = (root: HTMLDivElement | null) => {
tabListRef.current = root;
const viewport = tabScrollViewport(root);
if (!viewport) return;

const update = () => updateTabScrollState(viewport);
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey) return;
let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
Expand All @@ -1222,12 +1204,22 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
viewport.scrollLeft += delta;
if (viewport.scrollLeft === previousScrollLeft) return;
event.preventDefault();
updateTabScrollState();
update();
};

const content = viewport.firstElementChild;
const stopObserving = observeResize(content ? [viewport, content] : viewport, update);
viewport.addEventListener("scroll", update, { passive: true });
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [updateTabScrollState]);
update();

return () => {
tabListRef.current = null;
stopObserving();
viewport.removeEventListener("scroll", update);
viewport.removeEventListener("wheel", handleWheel);
};
};

return (
<PreviewPanelShell
Expand All @@ -1250,7 +1242,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
>
<ScrollArea
radius="none"
ref={tabListRef}
ref={attachTabList}
hideScrollbars
scrollFade
className="min-w-0 flex-1"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ChatRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function ChatRightPanel(props: ChatRightPanelProps) {
layoutControls,
...tabs
} = props;
const inlineSize = usePreviewPanelInlineSize(undefined, {
const inlineSize = usePreviewPanelInlineSize({
container: workspaceLayoutElement,
widthStorageKey,
});
Expand Down
Loading
Loading