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
51 changes: 51 additions & 0 deletions apps/web/src/agentUiExpandedStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* T3-CUSTOM(expbkt3): expanded agent view state.
*
* Only one view may own the transcript at a time — expanding a second must
* replace the first rather than stack, because the overlay is a single mount
* point and a stack would strand the older one underneath it.
*/
import { describe, expect, it, beforeEach } from "vite-plus/test";
import type { ScopedThreadRef } from "@t3tools/contracts";

import { useAgentUiExpandedStore } from "./agentUiExpandedStore";

const threadRef = { environmentId: "env-1", threadId: "thread-1" } as unknown as ScopedThreadRef;
const otherThreadRef = {
environmentId: "env-2",
threadId: "thread-2",
} as unknown as ScopedThreadRef;

describe("useAgentUiExpandedStore", () => {
beforeEach(() => {
useAgentUiExpandedStore.getState().collapse();
});

it("starts with nothing expanded", () => {
expect(useAgentUiExpandedStore.getState().expanded).toBeNull();
});

it("expands a view and collapses it again", () => {
useAgentUiExpandedStore.getState().expand({ threadRef, renderId: "aui_1" });
expect(useAgentUiExpandedStore.getState().expanded).toEqual({ threadRef, renderId: "aui_1" });

useAgentUiExpandedStore.getState().collapse();
expect(useAgentUiExpandedStore.getState().expanded).toBeNull();
});

it("replaces the expanded view rather than stacking a second one", () => {
const store = useAgentUiExpandedStore.getState();
store.expand({ threadRef, renderId: "aui_1" });
store.expand({ threadRef: otherThreadRef, renderId: "aui_2" });

expect(useAgentUiExpandedStore.getState().expanded).toEqual({
threadRef: otherThreadRef,
renderId: "aui_2",
});
});

it("is a no-op to collapse when nothing is expanded", () => {
useAgentUiExpandedStore.getState().collapse();
expect(useAgentUiExpandedStore.getState().expanded).toBeNull();
});
});
29 changes: 29 additions & 0 deletions apps/web/src/agentUiExpandedStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* T3-CUSTOM(expbkt3): which agent view, if any, is expanded over the transcript.
*
* Deliberately not persisted and deliberately global-but-single: expanding a
* view is a transient reading posture, and only one can own the transcript at a
* time. Keeping it in a store rather than in the timeline row is what lets the
* overlay mount beside the message list — outside the virtualized rows, which
* clip and recycle — while the card that opened it stays a plain row.
*/
import { create } from "zustand";

import type { ScopedThreadRef } from "@t3tools/contracts";

export interface ExpandedAgentUiView {
readonly threadRef: ScopedThreadRef;
readonly renderId: string;
}

interface AgentUiExpandedStoreState {
readonly expanded: ExpandedAgentUiView | null;
expand: (view: ExpandedAgentUiView) => void;
collapse: () => void;
}

export const useAgentUiExpandedStore = create<AgentUiExpandedStoreState>((set) => ({
expanded: null,
expand: (view) => set({ expanded: view }),
collapse: () => set({ expanded: null }),
}));
4 changes: 4 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ import {
} from "../rightPanelStore";
// T3-CUSTOM(expbkt3): native plan review surface.
import { PlanReviewPanel, useOpenPlanReviewDocumentId } from "../fork/planReviewSurface";
// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
import { AgentUiExpandedSurface } from "../fork/agentUiSurface";
import {
isPreviewSupportedInRuntime,
setActivePreviewTab,
Expand Down Expand Up @@ -7854,6 +7856,8 @@ function ChatViewContent(props: ChatViewProps) {
</div>
) : null}
{/* T3-CUSTOM(expbkt3): END */}
{/* T3-CUSTOM(expbkt3): an expanded agent view covers the transcript, not the composer. */}
<AgentUiExpandedSurface />
</div>

{/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
Expand Down
214 changes: 161 additions & 53 deletions apps/web/src/fork/agentUiSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import {
AGENT_UI_MIN_HEIGHT,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { memo, useMemo, useState, type ReactNode } from "react";
import { Maximize2Icon, XIcon } from "lucide-react";
import { memo, useEffect, useMemo, useState, type ReactNode } from "react";

import { useClientSettings } from "../hooks/useSettings";
import { agentUiEnvironment } from "../state/agentUi";
import { useAgentUiExpandedStore } from "../agentUiExpandedStore";
import { useEnvironmentQuery } from "../state/query";
import { cn } from "../lib/utils";

Expand Down Expand Up @@ -93,18 +95,21 @@ export function resolveEmbedSandbox(url: string, pageOrigin: string): string {
return origin === pageOrigin ? EMBED_SANDBOX_BASE : `${EMBED_SANDBOX_BASE} allow-same-origin`;
}

interface AgentUiSurfaceCardProps {
/**
* Fetches one render and mounts it. Shared by the inline card and the expanded
* overlay so both agree on sandboxing, loading and failure states — the sandbox
* rules in particular must never drift between the two.
*/
const AgentUiRenderFrame = memo(function AgentUiRenderFrame(props: {
readonly threadRef: ScopedThreadRef;
readonly surface: AgentUiSurfaceHandle;
}

function AgentUiSurfaceCardImpl({ threadRef, surface }: AgentUiSurfaceCardProps) {
const { environmentId, threadId } = threadRef;
const [collapsed, setCollapsed] = useState(false);
readonly renderId: string;
readonly onTitle?: ((title: string) => void) | undefined;
}) {
const { environmentId, threadId } = props.threadRef;
const query = useEnvironmentQuery(
agentUiEnvironment.render({
environmentId,
input: { threadId, renderId: surface.renderId },
input: { threadId, renderId: props.renderId },
}),
);
const render = query.data?.render ?? null;
Expand All @@ -117,55 +122,95 @@ function AgentUiSurfaceCardImpl({ threadRef, surface }: AgentUiSurfaceCardProps)
[render?.kind, render?.html],
);

const height = render ? clampHeight(render.height) : surface.height;
const title = render?.title ?? "Agent view";
const onTitle = props.onTitle;
const title = render?.title;
useEffect(() => {
if (onTitle && title) onTitle(title);
}, [onTitle, title]);

if (query.isPending && render === null) {
return (
<div className="flex h-full items-center justify-center text-secondary-label text-xs">
Loading view…
</div>
);
}
if (render === null) {
return (
<div className="flex h-full items-center justify-center px-4 text-center text-secondary-label text-xs">
{query.error ?? "This view is no longer available."}
</div>
);
}
if (srcDoc !== null) {
return (
<iframe
// Keying on the render makes the browser build a fresh document; an
// iframe does not re-run scripts on a srcDoc mutation alone.
key={render.renderId}
title={render.title}
srcDoc={srcDoc}
className={cn("size-full border-0 bg-white")}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
/>
);
}
if (render.url) {
return (
<iframe
key={render.renderId}
title={render.title}
src={render.url}
className="size-full border-0 bg-white"
sandbox={resolveEmbedSandbox(render.url, window.location.origin)}
referrerPolicy="no-referrer"
/>
);
}
return null;
});

interface AgentUiSurfaceCardProps {
readonly threadRef: ScopedThreadRef;
readonly surface: AgentUiSurfaceHandle;
}

function AgentUiSurfaceCardImpl({ threadRef, surface }: AgentUiSurfaceCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [title, setTitle] = useState("Agent view");
const expand = useAgentUiExpandedStore((state) => state.expand);

return (
<div className="mt-1 ms-7 overflow-hidden rounded-lg border border-border/60 bg-card">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left hover:bg-accent/20"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
>
<span className="min-w-0 flex-1 truncate font-medium text-secondary-label text-xs">
{title}
</span>
<span className="shrink-0 text-[0.6875rem] text-secondary-label/70">
<div className="flex items-center gap-1 px-1.5 py-1">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-0.5 text-left hover:bg-accent/20"
onClick={() => expand({ threadRef, renderId: surface.renderId })}
aria-label={`Expand ${title}`}
>
<span className="min-w-0 flex-1 truncate font-medium text-secondary-label text-xs">
{title}
</span>
<Maximize2Icon className="size-3 shrink-0 text-icon-muted opacity-70" aria-hidden />
</button>
<button
type="button"
className="shrink-0 rounded px-1.5 py-0.5 text-[0.6875rem] text-secondary-label/70 hover:bg-accent/20 hover:text-secondary-label"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
>
{collapsed ? "Show" : "Hide"}
</span>
</button>
</button>
</div>
{collapsed ? null : (
<div className="border-border/60 border-t" style={{ height }}>
{query.isPending && render === null ? (
<div className="flex h-full items-center justify-center text-secondary-label text-xs">
Loading view…
</div>
) : render === null ? (
<div className="flex h-full items-center justify-center px-4 text-center text-secondary-label text-xs">
{query.error ?? "This view is no longer available."}
</div>
) : srcDoc !== null ? (
<iframe
// Keying on the render makes the browser build a fresh document;
// an iframe does not re-run scripts on a srcDoc mutation alone.
key={render.renderId}
title={title}
srcDoc={srcDoc}
className={cn("size-full border-0 bg-white")}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
/>
) : render.url ? (
<iframe
key={render.renderId}
title={title}
src={render.url}
className="size-full border-0 bg-white"
sandbox={resolveEmbedSandbox(render.url, window.location.origin)}
referrerPolicy="no-referrer"
/>
) : null}
<div className="border-border/60 border-t" style={{ height: surface.height }}>
<AgentUiRenderFrame
threadRef={threadRef}
renderId={surface.renderId}
onTitle={setTitle}
/>
</div>
)}
</div>
Expand Down Expand Up @@ -196,3 +241,66 @@ export const AgentUiSurfaceRow = memo(function AgentUiSurfaceRow(props: {
</div>
);
});

/**
* The expanded view, filling the message area and leaving the composer usable.
*
* Mounted as the last child of the messages wrapper in `ChatView`, which is
* already `relative` and already excludes the input bar — so `absolute inset-0`
* covers exactly the transcript and nothing else. It cannot live inside the
* timeline row that opened it: those rows are virtualized, so they clip and get
* recycled out from under an overlay.
*/
export const AgentUiExpandedSurface = memo(function AgentUiExpandedSurface() {
const enabled = useClientSettings((settings) => settings.agentUiSurfacesEnabled);
const expanded = useAgentUiExpandedStore((state) => state.expanded);
const collapse = useAgentUiExpandedStore((state) => state.collapse);
const [title, setTitle] = useState("Agent view");

const open = enabled && expanded !== null;
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
collapse();
}
};
// Capture phase: the composer and the timeline both handle Escape, and the
// expanded view is the frontmost surface, so it answers first.
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [open, collapse]);

if (!open || expanded === null) return null;

return (
<div
className="absolute inset-0 z-40 flex flex-col bg-background"
role="dialog"
aria-label={title}
>
<div className="flex items-center gap-2 border-border/60 border-b px-3 py-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-secondary-label text-xs">
{title}
</span>
<button
type="button"
className="flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-secondary-label/70 hover:bg-accent/20 hover:text-secondary-label"
onClick={collapse}
aria-label="Close the expanded view"
>
<XIcon className="size-3" aria-hidden />
Close
</button>
</div>
<div className="min-h-0 flex-1">
<AgentUiRenderFrame
threadRef={expanded.threadRef}
renderId={expanded.renderId}
onTitle={setTitle}
/>
</div>
</div>
);
});
Loading