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
23 changes: 23 additions & 0 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { ClientSettings } from "@t3tools/contracts/settings";
import { createBrowserHistory, createHashHistory } from "@tanstack/react-router";

import { applyAppearanceContrast } from "./appearanceContrast";
import { applyAppearanceFontVariables } from "./appearanceFonts";
import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention";
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";
import { useComposerDraftStore } from "./composerDraftStore";
import { getClientSettings, subscribeClientSettings } from "./hooks/useSettings";
import {
syncDocumentElectronPlatformClasses,
Expand All @@ -16,6 +19,7 @@ import { prepareProviderAuthDelivery } from "./providerAuthDelivery";
import { getRouter, type AppRouter } from "./router";
import { appAtomRegistry } from "./rpc/atomRegistry";
import { primaryServerConfigAtom } from "./state/server";
import { resolveThreadRouteTarget } from "./threadRoutes";

/** Writes appearance settings onto the document, touching only the groups that changed. */
function startClientAppearance(): void {
Expand Down Expand Up @@ -84,6 +88,24 @@ function startDocumentTitle(): void {
);
}

/** Chat overlays belong to the thread they were opened in, so leaving its route drops them. */
function startThreadOverlayRetention(router: AppRouter): void {
router.subscribe("onResolved", () => {
const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
const draftSession =
target?.kind === "draft"
? useComposerDraftStore.getState().getDraftSession(target.draftId)
: null;
const threadRef =
target?.kind === "server"
? target.threadRef
: draftSession
? scopeThreadRef(draftSession.environmentId, draftSession.threadId)
: null;
retainThreadOverlaysForThread(threadRef ? scopedThreadKey(threadRef) : null);
});
}

/**
* Starts every app-lifetime service once, before the first render, and returns the router.
* They run for the life of the page, so their disposers are dropped here.
Expand All @@ -103,6 +125,7 @@ export function startAppServices(): AppRouter {
startClientAppearance();
startDocumentTitle();
startAgentOpenedPreviews();
startThreadOverlayRetention(router);

return router;
}
24 changes: 14 additions & 10 deletions apps/web/src/components/chat/ChatOverlayHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { useEffect } from "react";

import { appAtomRegistry } from "~/rpc/atomRegistry";
import { useRightPanelStore } from "~/rightPanelStore";
Expand All @@ -25,15 +24,26 @@ import {
import { Button } from "../ui/button";
import { Dialog } from "../ui/dialog";
import { WizardPopup } from "../ui/wizard";

// Atoms isolate overlay renders. Thread keys prevent overlays following thread switches.
import { registerThreadOverlay } from "./threadOverlayRetention";

/**
* Open state for overlays the chat view hosts. It lives in atoms rather than
* ChatView state so opening or closing one renders only its host, not the
* whole chat chrome. Each entry carries the thread it was opened for; a host
* shows only its own thread's entry, and leaving that thread's route drops it.
*/
type ThreadOverlayAtom<T> = Atom.Writable<{ readonly threadKey: string; readonly value: T } | null>;

function makeThreadOverlayAtom<T>(label: string): ThreadOverlayAtom<T> {
return Atom.make<{ readonly threadKey: string; readonly value: T } | null>(null).pipe(
const atom = Atom.make<{ readonly threadKey: string; readonly value: T } | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel(`chat:${label}`),
);
registerThreadOverlay((threadKey) => {
const entry = appAtomRegistry.get(atom);
if (entry !== null && entry.threadKey !== threadKey) closeThreadOverlay(atom);
});
return atom;
}

function closeThreadOverlay<T>(atom: ThreadOverlayAtom<T>): void {
Expand All @@ -42,12 +52,6 @@ function closeThreadOverlay<T>(atom: ThreadOverlayAtom<T>): void {

function useThreadOverlay<T>(atom: ThreadOverlayAtom<T>, threadKey: string | null): T | null {
const entry = useAtomValue(atom);
useEffect(
() => () => {
if (appAtomRegistry.get(atom)?.threadKey === threadKey) closeThreadOverlay(atom);
},
[atom, threadKey],
);
return threadKey !== null && entry?.threadKey === threadKey ? entry.value : null;
}

Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { SnapShotSource } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../../test/render";
import { ExpandedImageDialog } from "./ExpandedImageDialog";

const PIXEL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";

const snapShotSource: SnapShotSource = {
kind: "snap-shot",
capturedAt: "2026-01-01T00:00:00.000Z",
appName: "Finder",
windowTitle: "Downloads",
};

describe("ExpandedImageDialog", () => {
it("Escape closes a popup opened inside the dialog before the dialog", async () => {
const onClose = vi.fn();
await using screen = await render(
<ExpandedImageDialog
preview={{ images: [{ src: PIXEL, name: "shot.png", source: snapShotSource }], index: 0 }}
onClose={onClose}
/>,
// The dialog renders no links and reads no route.
{ router: false },
);

await userEvent.click(screen.getByRole("button", { name: "No accessibility data" }));
await expect.element(screen.getByText("Accessibility data")).toBeVisible();

await userEvent.keyboard("{Escape}");
await expect.element(screen.getByText("Accessibility data")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();

// Focus returns to the popup's trigger and opens its tooltip; leave it for a plain control.
(screen.getByRole("button", { name: "Close image preview" }).element() as HTMLElement).focus();
await userEvent.keyboard("{Escape}");
await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce());
});
});
92 changes: 47 additions & 45 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState, type ReactNode, type KeyboardEvent } from "react";
import { memo, useRef, useState, type ReactNode, type KeyboardEvent } from "react";
import { flushSync } from "react-dom";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
import { Image as ImageGlyph, Text as TextGlyph } from "lucide";
import { Button } from "../ui/button";
Expand All @@ -23,6 +24,7 @@ import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { expandedImageKey } from "./ExpandedImagePreview";
import { registerThreadOverlay } from "./threadOverlayRetention";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand Down Expand Up @@ -76,6 +78,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
const [returnFocusTarget] = useState(() =>
document.activeElement instanceof HTMLElement ? document.activeElement : null,
);
// The host unmounts the dialog outright, so Base UI never runs finalFocus. Without
// this, focus stays on the removed dialog, and the composer that owned the opener
// reads that as a blur and rests.
const close = () => {
flushSync(onClose);
if (returnFocusTarget?.isConnected) returnFocusTarget.focus({ preventScroll: true });
};
const closeButtonRef = useRef<HTMLButtonElement>(null);
const imageCount = preview.images.length;
const index =
Expand All @@ -92,7 +101,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
...source,
onOpenFile: () => {
openFile();
onClose();
close();
},
}
: source;
Expand All @@ -101,18 +110,6 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
setImageOffset((current) => current + direction);
};

// Restore opener focus to prevent composer collapse.
const openerRef = useRef<Element | null>(null);
useEffect(() => {
openerRef.current = document.activeElement;
return () => {
const opener = openerRef.current;
if (opener instanceof HTMLElement && opener.isConnected) {
opener.focus({ preventScroll: true });
}
};
}, []);

const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
return;
Expand All @@ -134,17 +131,6 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
navigateImage(1);
};

useEffect(() => {
const onEscape = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape" || isContextMenuOpen()) return;
event.preventDefault();
event.stopPropagation();
onClose();
};
window.addEventListener("keydown", onEscape, { capture: true });
return () => window.removeEventListener("keydown", onEscape, { capture: true });
}, [onClose]);

if (!item) return null;
const mediaLabel = item.type === "video" ? "video" : "image";
const openOriginalLink =
Expand All @@ -163,8 +149,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
onOpenChange={(open, eventDetails) => {
if (open) return;
// Escape belongs to an open context menu first.
if (eventDetails.reason === "escape-key" && isContextMenuOpen()) {
eventDetails.cancel();
return;
}
close();
}}
>
<DialogPopup
Expand All @@ -177,7 +169,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
initialFocus={closeButtonRef}
finalFocus={() => returnFocusTarget}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
if (event.target === event.currentTarget) close();
}}
>
<DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>
Expand All @@ -187,7 +179,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
size="icon-sm"
variant="media-close"
className="absolute right-0 top-0 z-20"
onClick={onClose}
onClick={close}
aria-label={`Close ${mediaLabel} preview`}
>
<XIcon />
Expand Down Expand Up @@ -286,35 +278,45 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
});

// Keep preview state outside ChatView to isolate dialog renders.
const expandedImagePreviewAtom = Atom.make<{
type ExpandedImagePreviewState = {
readonly threadKey: string;
readonly preview: ExpandedImagePreview;
} | null>(null).pipe(Atom.keepAlive, Atom.withLabel("chat:expanded-image-preview"));
} | null;

const expandedImagePreviewAtom = Atom.make<ExpandedImagePreviewState>(null).pipe(
Atom.keepAlive,
Atom.withLabel("chat:expanded-image-preview"),
);

function ownedSrc(state: ExpandedImagePreviewState): string | null {
const item = state?.preview.images[state.preview.index];
return item?.ownsSrc === true ? item.src : null;
}

// The preview owns a blob URL made for it, so whatever replaces it releases that URL.
function setExpandedImagePreview(next: ExpandedImagePreviewState): void {
const previous = ownedSrc(appAtomRegistry.get(expandedImagePreviewAtom));
appAtomRegistry.set(expandedImagePreviewAtom, next);
if (previous !== null && previous !== ownedSrc(next)) URL.revokeObjectURL(previous);
}

export function openExpandedImagePreview(threadKey: string, preview: ExpandedImagePreview): void {
appAtomRegistry.set(expandedImagePreviewAtom, { threadKey, preview });
setExpandedImagePreview({ threadKey, preview });
}

function closeExpandedImagePreview(): void {
appAtomRegistry.set(expandedImagePreviewAtom, null);
setExpandedImagePreview(null);
}

function closeExpandedImagePreviewFor(threadKey: string): void {
if (appAtomRegistry.get(expandedImagePreviewAtom)?.threadKey === threadKey) {
closeExpandedImagePreview();
}
}
// Leaving the thread's route (or the chat view) drops its preview.
registerThreadOverlay((threadKey) => {
const current = appAtomRegistry.get(expandedImagePreviewAtom);
if (current !== null && current.threadKey !== threadKey) closeExpandedImagePreview();
});

export function ExpandedImageDialogHost({ threadKey }: { readonly threadKey: string }) {
const state = useAtomValue(expandedImagePreviewAtom);
const preview = state?.threadKey === threadKey ? state.preview : null;
const item = preview?.images[preview.index];
const ownedSrc = item?.ownsSrc === true ? item.src : null;
useEffect(() => {
if (ownedSrc === null) return;
return () => URL.revokeObjectURL(ownedSrc);
}, [ownedSrc]);
useEffect(() => () => closeExpandedImagePreviewFor(threadKey), [threadKey]);
if (preview === null) return null;
return (
<ExpandedImageDialog
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/chat/HtmlRenderFrame.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../../test/render";

vi.mock(import("~/assets/longLivedAssetUrls"), () => ({
useLongLivedAssetUrl: () => ({
_tag: "Success",
url: "about:blank",
expiresAt: Date.now() + 60 * 60_000,
}),
}));
vi.mock(import("~/assets/assetUrls"), async (importOriginal) => ({
...(await importOriginal()),
useAssetUrlRefresh: () => async () => "about:blank",
}));

import { HtmlRenderFrame } from "./HtmlRenderFrame";

describe("HtmlRenderFrame", () => {
it("reports leaving full screen when it unmounts while expanded", async () => {
const onFullscreenChange = vi.fn();
await using screen = await render(
<HtmlRenderFrame
environmentId={EnvironmentId.make("environment")}
htmlRender={{ attachmentId: "attachment", title: "Report", height: 200 }}
threadRef={undefined}
onFullscreenChange={onFullscreenChange}
/>,
);

await userEvent.click(screen.getByRole("button", { name: "Open full screen" }));
await expect.element(screen.getByRole("dialog")).toBeVisible();
expect(onFullscreenChange).toHaveBeenLastCalledWith(true);

await screen.unmount();
expect(onFullscreenChange).toHaveBeenLastCalledWith(false);
});
});
Loading
Loading