diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3d29883887a7..884cff29254f 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -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, @@ -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 { @@ -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. @@ -103,6 +125,7 @@ export function startAppServices(): AppRouter { startClientAppearance(); startDocumentTitle(); startAgentOpenedPreviews(); + startThreadOverlayRetention(router); return router; } diff --git a/apps/web/src/components/chat/ChatOverlayHosts.tsx b/apps/web/src/components/chat/ChatOverlayHosts.tsx index f8e372156953..fd5ecb669dcc 100644 --- a/apps/web/src/components/chat/ChatOverlayHosts.tsx +++ b/apps/web/src/components/chat/ChatOverlayHosts.tsx @@ -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"; @@ -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 = Atom.Writable<{ readonly threadKey: string; readonly value: T } | null>; function makeThreadOverlayAtom(label: string): ThreadOverlayAtom { - 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(atom: ThreadOverlayAtom): void { @@ -42,12 +52,6 @@ function closeThreadOverlay(atom: ThreadOverlayAtom): void { function useThreadOverlay(atom: ThreadOverlayAtom, 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; } diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx new file mode 100644 index 000000000000..1ea8d4c558f3 --- /dev/null +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -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( + , + // 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()); + }); +}); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index f1a191213b54..aadbcab79b12 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -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"; @@ -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; @@ -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(null); const imageCount = preview.images.length; const index = @@ -92,7 +101,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ ...source, onOpenFile: () => { openFile(); - onClose(); + close(); }, } : source; @@ -101,18 +110,6 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ setImageOffset((current) => current + direction); }; - // Restore opener focus to prevent composer collapse. - const openerRef = useRef(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) => { if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement) return; @@ -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 = @@ -163,8 +149,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return ( { - 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(); }} > returnFocusTarget} onClick={(event) => { - if (event.target === event.currentTarget) onClose(); + if (event.target === event.currentTarget) close(); }} > Expanded {mediaLabel} preview @@ -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`} > @@ -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(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 ( ({ + 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( + , + ); + + 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); + }); +}); diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx index cbf9edaefaa0..19fbe6108ae5 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx @@ -6,7 +6,7 @@ import { type HtmlRenderReference, } from "@t3tools/shared/htmlRender"; import { Maximize2Icon } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import { useLongLivedAssetUrl } from "~/assets/longLivedAssetUrls"; @@ -27,16 +27,13 @@ export function HtmlRenderFrame(props: { readonly onFullscreenChange?: (fullscreen: boolean) => void; }) { const { attachmentId, title } = props.htmlRender; - const boxRef = useRef(null); const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); - useLayoutEffect(() => { - const box = boxRef.current; - if (!box) return; + const observeBox = (box: HTMLDivElement) => { setWidth(box.clientWidth); return observeResize(box, ([entry]) => { if (entry) setWidth(entry.contentRect.width); }); - }, []); + }; // Client fonts can exceed the server-measured height and cause nested scrolling. const [contentHeight, setContentHeight] = useState(); const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight); @@ -59,14 +56,17 @@ export function HtmlRenderFrame(props: { const refresh = useAssetUrlRefresh(props.environmentId, resource); const [expandedSrc, setExpandedSrc] = useState(null); const { onFullscreenChange } = props; + // Tied to the viewer's lifetime: unmounting while expanded must still release + // the timeline's follow pin, which a close handler alone never reaches. + const expanded = expandedSrc !== null; useEffect(() => { - if (expandedSrc === null) return; + if (!expanded) return; onFullscreenChange?.(true); return () => onFullscreenChange?.(false); - }, [expandedSrc, onFullscreenChange]); + }, [expanded, onFullscreenChange]); return ( -
+
{src !== null ? ( <> + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ), + }); const [wrapped, setWrapped] = useState(() => getClientSettings().wordWrap); - const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; const command = code.trim(); @@ -167,43 +178,7 @@ export function MarkdownCodeBlock({ // make the rendered command differ from what the terminal would receive. !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); - const handleCopy = () => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure( - { - operation: "copy-code-block", - language, - ...(fenceTitle ? { fenceTitle } : {}), - }, - cause, - ); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); + const handleCopy = () => copyToClipboard(code); const copyButton = ( @@ -358,32 +333,33 @@ function UncachedShikiCodeBlock({ : null; useEffect(() => () => incrementalHighlight?.dispose(), [incrementalHighlight]); const highlighted = (() => { - try { - if (incrementalHighlight) return incrementalHighlight(code); - return preserveLines - ? highlightCodeToHast(highlighter, code, language, themeName) - : highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - return preserveLines - ? highlightCodeToHast(highlighter, code, "text", themeName) - : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - })(); - - useEffect(() => { + const result = (() => { + try { + if (incrementalHighlight) return incrementalHighlight(code); + return preserveLines + ? highlightCodeToHast(highlighter, code, language, themeName) + : highlighter.codeToHtml(code, { lang: language, theme: themeName }); + } catch (error) { + console.warn( + `Code highlighting failed for language "${language}", falling back to plain text.`, + error instanceof Error ? error.message : error, + ); + return preserveLines + ? highlightCodeToHast(highlighter, code, "text", themeName) + : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + } + })(); + // A finished block is cached for the next mount; a streaming one is still changing. if (!isStreaming) { - const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); + const highlightedHtml = typeof result === "string" ? result : toHtml(result); highlightedCodeCache.set( cacheKey, highlightedHtml, estimateHighlightedSize(highlightedHtml, code), ); } - }, [cacheKey, code, highlighted, isStreaming]); + return result; + })(); return typeof highlighted === "string" ? (
diff --git a/apps/web/src/components/chat/MarkdownCopy.browser.test.tsx b/apps/web/src/components/chat/MarkdownCopy.browser.test.tsx new file mode 100644 index 000000000000..9b50489828a9 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownCopy.browser.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; +import { render } from "../../../test/render"; +import { MarkdownCodeBlock } from "./MarkdownCodeBlock"; +import { MarkdownTable } from "./MarkdownTable"; + +// Insecure origins (a LAN or tailnet address over plain http) have no async clipboard. +function withoutAsyncClipboard() { + const original = Object.getOwnPropertyDescriptor(Navigator.prototype, "clipboard"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); + return { + execCommand, + [Symbol.dispose]() { + execCommand.mockRestore(); + Reflect.deleteProperty(navigator, "clipboard"); + if (original) Object.defineProperty(Navigator.prototype, "clipboard", original); + }, + }; +} + +describe("markdown copy without the async clipboard", () => { + it("copies a code block through the execCommand fallback", async () => { + await using screen = await render( + +
+          echo hi
+        
+
, + // The block renders no links and reads no route. + { router: false }, + ); + using clipboard = withoutAsyncClipboard(); + + await userEvent.click(screen.getByRole("button", { name: "Copy code" })); + await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect(clipboard.execCommand).toHaveBeenCalledWith("copy"); + }); + + it("copies a table through the execCommand fallback", async () => { + await using screen = await render( + + + + Name + + + + + Ada + + + , + // The table renders no links and reads no route. + { router: false }, + ); + using clipboard = withoutAsyncClipboard(); + + await userEvent.click(screen.getByRole("button", { name: "Copy table" })); + await userEvent.click(page.getByRole("menuitem", { name: "Copy as Markdown" })); + await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect(clipboard.execCommand).toHaveBeenCalledWith("copy"); + }); +}); diff --git a/apps/web/src/components/chat/MarkdownTable.tsx b/apps/web/src/components/chat/MarkdownTable.tsx index 4f75a74193cb..dbeafeb5dcc4 100644 --- a/apps/web/src/components/chat/MarkdownTable.tsx +++ b/apps/web/src/components/chat/MarkdownTable.tsx @@ -1,6 +1,7 @@ import { Check, Copy, Maximize2, Minimize2 } from "lucide"; -import { useEffect, useLayoutEffect, useRef, useState, type ComponentProps } from "react"; +import { useRef, useState, type ComponentProps } from "react"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { getClientSettings } from "../../hooks/useSettings"; import { observeResize } from "../../lib/observeResize"; import { @@ -41,78 +42,54 @@ function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { const containerRef = useRef(null); - const tableRef = useRef(null); const [expanded, setExpanded] = useState(() => getClientSettings().wordWrap); const [hasClippedCells, setHasClippedCells] = useState(false); - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); + const { copyToClipboard, isCopied: copied } = useCopyToClipboard<"markdown" | "csv">({ + timeout: 1200, + onError: (cause, format) => + reportMarkdownActionFailure({ operation: "copy-table", format }, cause), + }); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; const copyLabel = copied ? "Copied" : "Copy table"; - const measureClippedCells = () => { - const container = containerRef.current; - const table = tableRef.current; + // React calls this with null when a previous attach returned no cleanup. + const attachContainer = (container: HTMLDivElement | null) => { + containerRef.current = container; + const table = container?.querySelector("table"); if (!container || !table) return; - setHasClippedCells(tableHasClippedCells(container, table)); + const measure = () => setHasClippedCells(tableHasClippedCells(container, table)); + measure(); + // A streamed cell can grow until it clips while the table keeps its size, and a + // narrower column can clip a cell without this component rendering. + const mutations = new MutationObserver(measure); + mutations.observe(table, { childList: true, characterData: true, subtree: true }); + const stopResize = observeResize(table, measure); + return () => { + mutations.disconnect(); + stopResize(); + containerRef.current = null; + }; }; - // A streamed cell can grow until it clips while the table keeps its size, - // so remeasure after every render, not only on resize. - useLayoutEffect(measureClippedCells); - - // Column width changes can clip a cell without rendering this table. - useLayoutEffect(() => { - const table = tableRef.current; - if (!table) return; - return observeResize(table, measureClippedCells); - }, [measureClippedCells]); - const handleCopy = (format: "markdown" | "csv") => { const table = containerRef.current?.querySelector("table"); - if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - const text = + if (!table) return; + copyToClipboard( format === "markdown" ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table); - void navigator.clipboard - .writeText(text) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure({ operation: "copy-table", format }, cause); - }); + : serializeTableElementToCsv(table), + format, + ); }; - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - return (
- - {children} -
+ {children}
{/* Stays within the row's px-1: the chat lane clips sideways overflow. */}
diff --git a/apps/web/src/components/chat/McpAppFrame.tsx b/apps/web/src/components/chat/McpAppFrame.tsx index 3da6b4d3dae3..430a6b741264 100644 --- a/apps/web/src/components/chat/McpAppFrame.tsx +++ b/apps/web/src/components/chat/McpAppFrame.tsx @@ -19,16 +19,18 @@ import { type McpAppReference, } from "@t3tools/shared/mcpApp"; import { Minimize2Icon } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type FocusEvent, type ToggleEvent } from "react"; import { flushSync } from "react-dom"; -import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; +import { useLongLivedAssetUrl } from "~/assets/longLivedAssetUrls"; import { APP_VERSION } from "~/branding"; import { isConfirmDialogActive, requestConfirmDialog } from "~/confirmDialog"; import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; import { isElectron } from "~/env"; import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; import { useTurnItemDetail } from "~/state/queries"; import { mcpAppEnvironment } from "~/state/mcpApps"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -43,7 +45,6 @@ const commandFailure = (result: { ); }; -const MIN_URL_LIFE_MS = 5 * 60_000; const fullscreenSupported = typeof HTMLElement !== "undefined" && "popover" in HTMLElement.prototype; @@ -51,6 +52,19 @@ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // Exclude activation left by input elsewhere in T3. const PAGE_ACTIVATION_MS = 5_000; +// Frame input does not bubble here. An app can refocus itself, so focus +// alone cannot exclude activation from input elsewhere in T3. +let lastPageInputAt = Number.NEGATIVE_INFINITY; +if (typeof window !== "undefined") { + const note = () => { + lastPageInputAt = performance.now(); + }; + if (navigator.userActivation?.isActive) note(); + for (const type of ["pointerdown", "pointerup", "touchend", "keydown"] as const) { + window.addEventListener(type, note, true); + } +} + function saveBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -82,15 +96,22 @@ export function McpAppFrame(props: { const [displayMode, setDisplayMode] = useState("inline"); const [closed, setClosed] = useState(false); const [documentGeneration, setDocumentGeneration] = useState(0); - useLayoutEffect(() => { - const box = boxRef.current; - if (!box) return; + // Closing and reopening replaces the box, which re-attaches this. + const attachBox = (box: HTMLDivElement) => { + boxRef.current = box; setWidth(box.clientWidth); - return observeResize(box, ([entry]) => { + const stopResize = observeResize(box, ([entry]) => { if (entry) setWidth(Math.round(entry.contentRect.width)); + // Full screen reports the viewport, which can change height alone. + if (fullscreenSupported && box.matches(":popover-open")) { + hostRef.current?.updateHostContext(); + } }); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box. - }, [closed]); + return () => { + stopResize(); + boxRef.current = null; + }; + }; const resource = { _tag: "attachment" as const, @@ -100,27 +121,10 @@ export function McpAppFrame(props: { disposition: "inline" as const, renderIntent: "mcp-app" as const, }; - const asset = useAssetUrlState(props.environmentId, resource); - const refreshAsset = useAssetUrlRefresh(props.environmentId, resource); - // Retain the URL to avoid reloads. Re-mint near expiry; frames cannot report failed loads. + // The frame keeps its first URL: a re-minted one would reload the app. const [src, setSrc] = useState(null); - const [mintFailed, setMintFailed] = useState(false); - const minting = useRef(false); - const cachedUrl = asset._tag === "Success" ? asset.url : null; - const cachedExpiresAt = asset._tag === "Success" ? asset.expiresAt : 0; - useEffect(() => { - if (src !== null || cachedUrl === null || minting.current) return; - if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) { - // oxlint-disable-next-line react/set-state-in-effect -- Adopts the cached URL once it is known to last. - setSrc(cachedUrl); - return; - } - minting.current = true; - void refreshAsset().then( - (url) => (url === null ? setMintFailed(true) : setSrc(url)), - () => setMintFailed(true), - ); - }, [src, cachedUrl, cachedExpiresAt, refreshAsset]); + const asset = useLongLivedAssetUrl(src === null ? props.environmentId : null, resource); + if (src === null && asset._tag === "Success") setSrc(asset.url); const detail = useTurnItemDetail({ environmentId: props.environmentId, @@ -142,14 +146,19 @@ export function McpAppFrame(props: { const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const toolDefinition = useEnvironmentQuery( + mcpAppEnvironment.toolInfoQuery({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, + }), + ).data?.tool; const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { reportFailure: false, }); - const [toolDefinition, setToolDefinition] = useState(undefined); // Read live values without rebuilding the host and losing the app session. - const live = { + const readLive = useStableCallback(() => ({ theme, width, props, @@ -159,80 +168,42 @@ export function McpAppFrame(props: { updateModelContext, displayMode, toolDefinition, - }; + })); const hostRef = useRef(null); - const latest = useRef(live); - useEffect(() => { - latest.current = live; - }); - - // Frame input does not bubble here. An app can refocus itself, so focus - // alone cannot exclude activation from input elsewhere in T3. - const lastPageInputAt = useRef(Number.NEGATIVE_INFINITY); - useEffect(() => { - const note = () => { - lastPageInputAt.current = performance.now(); - }; - if (navigator.userActivation?.isActive) note(); - const types = ["pointerdown", "pointerup", "touchend", "keydown"] as const; - for (const type of types) window.addEventListener(type, note, true); - return () => { - for (const type of types) window.removeEventListener(type, note, true); - }; - }, []); - useEffect(() => { - const box = boxRef.current; - if (box === null || !fullscreenSupported) return; - const shown = box.matches(":popover-open"); - if (displayMode === "fullscreen" && !shown) box.showPopover(); - if (displayMode !== "fullscreen" && shown) box.hidePopover(); - if (displayMode === "fullscreen") { - latest.current.props.onFullscreenChange?.(true); - return () => latest.current.props.onFullscreenChange?.(false); - } - }, [displayMode]); const [stepAsideFor, setStepAsideFor] = useState(props.awaitingUser); if (stepAsideFor !== props.awaitingUser) { setStepAsideFor(props.awaitingUser); if (props.awaitingUser === true) setDisplayMode("inline"); } - useEffect(() => { - if (displayMode !== "fullscreen") return; - const leave = () => setDisplayMode("inline"); - const onFocus = (event: FocusEvent) => { - if (event.target instanceof Node && !boxRef.current?.contains(event.target)) leave(); - }; - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") leave(); - }; - const onResize = () => hostRef.current?.updateHostContext(); - document.addEventListener("focusin", onFocus); - window.addEventListener("keydown", onKey); - window.addEventListener("resize", onResize); + // The box is in the top layer exactly while its full-screen chrome is + // mounted, so every way out of full screen (including the step-aside above, + // decided during render) closes the popover and releases the timeline pin. + const attachFullscreenChrome = (chrome: HTMLDivElement) => { + const box = chrome.parentElement; + if (box === null) return; + if (!box.matches(":popover-open")) box.showPopover(); + readLive().props.onFullscreenChange?.(true); return () => { - document.removeEventListener("focusin", onFocus); - window.removeEventListener("keydown", onKey); - window.removeEventListener("resize", onResize); + if (box.matches(":popover-open")) box.hidePopover(); + readLive().props.onFullscreenChange?.(false); }; - }, [displayMode]); + }; + // Light dismiss (Escape) closes the popover without React. + const onBoxToggle = (event: ToggleEvent) => { + if (event.newState === "closed") setDisplayMode("inline"); + }; + const onBoxBlur = (event: FocusEvent) => { + const next = event.relatedTarget; + if ( + displayMode === "fullscreen" && + next instanceof Node && + !event.currentTarget.contains(next) + ) { + setDisplayMode("inline"); + } + }; - useEffect(() => { - let cancelled = false; - void latest.current - .toolInfo({ - environmentId: props.environmentId, - input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, - }) - .then((info) => { - if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { - setToolDefinition(info.value.tool); - } - }); - return () => { - cancelled = true; - }; - }, [props.environmentId, props.threadId, props.itemId, app.tool]); // Navigation retains the frame window; reject second loads to prevent impersonation. const loads = useRef({ generation: 0, count: 0 }); const onFrameLoad = () => { @@ -246,10 +217,11 @@ export function McpAppFrame(props: { } }; - useEffect(() => { - if (src === null) return; + // The host belongs to this frame element, which is replaced when the document is reopened. + const attachFrame = (frame: HTMLIFrameElement) => { + frameRef.current = frame; const hostContext = (): McpAppHostContext => { - const current = latest.current; + const current = readLive(); return { theme: current.theme.appearance, styles: { variables: mcpAppStyleVariables(current.theme.variables) }, @@ -287,7 +259,7 @@ export function McpAppFrame(props: { }; const target = () => frameRef.current?.contentWindow ?? null; const scope = () => { - const { environmentId, threadId, itemId } = latest.current.props; + const { environmentId, threadId, itemId } = readLive().props; return { environmentId, input: { threadId, itemId } }; }; const host = makeMcpAppHost({ @@ -298,7 +270,7 @@ export function McpAppFrame(props: { hostContext, callTool: async ({ name, arguments: args }) => { const { environmentId, input } = scope(); - const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); + const info = await readLive().toolInfo({ environmentId, input: { ...input, name } }); if (info._tag !== "Success") throw commandFailure(info); if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); if (!info.value.readOnly) { @@ -307,7 +279,7 @@ export function McpAppFrame(props: { ); if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); } - const result = await latest.current.callTool({ + const result = await readLive().callTool({ environmentId, input: { ...input, name, arguments: args }, }); @@ -316,7 +288,7 @@ export function McpAppFrame(props: { }, readResource: async ({ uri }) => { const { environmentId, input } = scope(); - const result = await latest.current.readResource({ + const result = await readLive().readResource({ environmentId, input: { ...input, uri }, }); @@ -330,7 +302,7 @@ export function McpAppFrame(props: { window.open(url, "_blank", "noopener,noreferrer"); }, sendMessage: async (text) => { - const send = latest.current.props.onSendMessage; + const send = readLive().props.onSendMessage; if (send === undefined) throw new McpAppHostRefusal("Messages are not available here."); const approved = await ask(`Send this message from ${app.server}?\n${text}`); if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); @@ -338,12 +310,12 @@ export function McpAppFrame(props: { }, updateModelContext: async (context) => { const { environmentId, input } = scope(); - const result = await latest.current.updateModelContext({ + const result = await readLive().updateModelContext({ environmentId, input: { ...input, ...context, - conversationThreadId: latest.current.props.conversationThreadId, + conversationThreadId: readLive().props.conversationThreadId, }, }); if (result._tag !== "Success") throw commandFailure(result); @@ -352,14 +324,15 @@ export function McpAppFrame(props: { const otherFullscreen = document.querySelector("[data-mcp-app-fullscreen]"); if ( mode === "fullscreen" && - (document.activeElement !== frameRef.current || + (!fullscreenSupported || + document.activeElement !== frameRef.current || !navigator.userActivation?.isActive || - performance.now() - lastPageInputAt.current < PAGE_ACTIVATION_MS || - latest.current.props.awaitingUser === true || + performance.now() - lastPageInputAt < PAGE_ACTIVATION_MS || + readLive().props.awaitingUser === true || isConfirmDialogActive() || (otherFullscreen !== null && otherFullscreen !== boxRef.current)) ) { - return latest.current.displayMode; + return readLive().displayMode; } setDisplayMode(mode); return mode; @@ -375,7 +348,7 @@ export function McpAppFrame(props: { bytes = file.bytes; } else { const { environmentId, input } = scope(); - const read = await latest.current.readResource({ + const read = await readLive().readResource({ environmentId, input: { ...input, uri: file.uri }, }); @@ -393,7 +366,7 @@ export function McpAppFrame(props: { } }, onRequestTeardown: () => { - if (latest.current.displayMode === "fullscreen") { + if (readLive().displayMode === "fullscreen") { setDisplayMode("inline"); return; } @@ -401,7 +374,7 @@ export function McpAppFrame(props: { }, onSizeChanged: (size) => { // Ignore fullscreen height when restoring inline sizing. - if (latest.current.displayMode === "fullscreen") return; + if (readLive().displayMode === "fullscreen") return; if (size.height !== undefined) setHeight(clampMcpAppHeight(size.height)); }, }); @@ -414,9 +387,9 @@ export function McpAppFrame(props: { window.removeEventListener("message", receive); void host.teardown(); hostRef.current = null; + frameRef.current = null; }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A reopened document needs a new host. - }, [src, app, documentGeneration]); + }; useEffect(() => { hostRef.current?.updateHostContext(); @@ -436,7 +409,6 @@ export function McpAppFrame(props: { size="xs" variant="ghost" onClick={() => { - minting.current = false; setSrc(null); setDocumentGeneration((value) => value + 1); setClosed(false); @@ -453,9 +425,11 @@ export function McpAppFrame(props: { // Preserve inline row height during fullscreen to prevent timeline jumps.
{fullscreen ? ( -
+
{app.server}