From 400f9a214e0740bd8172ba12813d8159f49a9397 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 14 Mar 2026 17:55:05 -0700 Subject: [PATCH] Add transcript virtualization to Foundry UI (#255) --- docs/react-components.mdx | 10 + .../packages/client/src/workbench-model.ts | 64 ++ foundry/packages/frontend/package.json | 3 +- .../frontend/src/components/mock-layout.tsx | 67 +- .../components/mock-layout/message-list.tsx | 84 ++- .../src/components/mock-layout/sidebar.tsx | 652 +++++++----------- frontend/packages/inspector/src/App.tsx | 8 +- .../src/components/chat/ChatPanel.tsx | 8 +- .../components/chat/InspectorConversation.tsx | 9 +- pnpm-lock.yaml | 58 +- sdks/react/package.json | 1 + sdks/react/src/AgentConversation.tsx | 24 +- sdks/react/src/AgentTranscript.tsx | 309 ++++++--- sdks/react/src/index.ts | 1 + sdks/react/src/useTranscriptVirtualizer.ts | 58 ++ 15 files changed, 780 insertions(+), 576 deletions(-) create mode 100644 sdks/react/src/useTranscriptVirtualizer.ts diff --git a/docs/react-components.mdx b/docs/react-components.mdx index 0fa41b0..93183b2 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -12,6 +12,7 @@ Current exports: - `ProcessTerminal` for attaching to a running tty process - `AgentTranscript` for rendering session/message timelines without bundling any styles - `ChatComposer` for a reusable prompt input/send surface +- `useTranscriptVirtualizer` for wiring large transcript lists to a scroll container ## Install @@ -184,11 +185,20 @@ Useful props: - `className`: root class hook - `classNames`: slot-level class hooks for styling from outside the package +- `scrollRef` + `virtualize`: opt into TanStack Virtual against an external scroll container - `renderMessageText`: custom text or markdown renderer - `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides - `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides - `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels +## Transcript virtualization hook + +`useTranscriptVirtualizer` exposes the same TanStack Virtual behavior used by `AgentTranscript` when `virtualize` is enabled. + +- Pass the grouped transcript rows you want to virtualize +- Pass a `scrollRef` that points at the actual scrollable element +- Use it when you need transcript-aware virtualization outside the stock `AgentTranscript` renderer + ## Composer and conversation `ChatComposer` is the headless message input. `AgentConversation` composes `AgentTranscript` and `ChatComposer` so apps can reuse the transcript/composer pairing without pulling in Inspector session chrome. diff --git a/foundry/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workbench-model.ts index 206d08a..2affb4d 100644 --- a/foundry/packages/client/src/workbench-model.ts +++ b/foundry/packages/client/src/workbench-model.ts @@ -235,6 +235,41 @@ function minutesAgo(minutes: number): number { return NOW_MS - minutes * 60_000; } +function buildTranscriptStressMessages(pairCount: number): LegacyMessage[] { + const startedAtMs = NOW_MS - pairCount * 8_000; + const messages: LegacyMessage[] = []; + + for (let index = 0; index < pairCount; index++) { + const sequence = index + 1; + const createdAtMs = startedAtMs + index * 8_000; + + messages.push({ + id: `stress-user-${sequence}`, + role: "user", + agent: null, + createdAtMs, + lines: [ + `Stress prompt ${sequence}: summarize the current state of the transcript virtualizer.`, + `Keep the answer focused on scroll position, render cost, and preserved expansion state.`, + ], + }); + + messages.push({ + id: `stress-agent-${sequence}`, + role: "agent", + agent: "codex", + createdAtMs: createdAtMs + 3_000, + lines: [ + `Stress reply ${sequence}: the list should only render visible rows plus overscan while preserving scroll anchoring near the bottom.`, + `Grouping, minimap navigation, and per-row UI should remain stable even as older rows unmount.`, + ], + durationMs: 2_500, + }); + } + + return messages; +} + export function parseDiffLines(diff: string): ParsedDiffLine[] { return diff.split("\n").map((text, index) => { if (text.startsWith("@@")) { @@ -1189,6 +1224,35 @@ export function buildInitialTasks(): Task[] { fileTree: [], minutesUsed: 0, }, + { + id: "stress-transcript", + repoId: "sandbox-agent", + title: "Transcript virtualization stress test", + status: "idle", + repoName: "rivet-dev/sandbox-agent", + updatedAtMs: minutesAgo(40), + branch: "perf/transcript-virtualizer", + pullRequest: null, + tabs: [ + { + id: "stress-transcript-tab", + sessionId: "stress-transcript-session", + sessionName: "Virtualizer stress session", + agent: "Codex", + model: "gpt-5.3-codex", + status: "idle", + thinkingSinceMs: null, + unread: false, + created: true, + draft: { text: "", attachments: [], updatedAtMs: null }, + transcript: transcriptFromLegacyMessages("stress-transcript-tab", buildTranscriptStressMessages(1600)), + }, + ], + fileChanges: [], + diffs: {}, + fileTree: [], + minutesUsed: 18, + }, { id: "status-running", repoId: "sandbox-agent", diff --git a/foundry/packages/frontend/package.json b/foundry/packages/frontend/package.json index 6a2e3c4..793a12d 100644 --- a/foundry/packages/frontend/package.json +++ b/foundry/packages/frontend/package.json @@ -10,11 +10,12 @@ "test": "vitest run" }, "dependencies": { - "@sandbox-agent/react": "workspace:*", "@sandbox-agent/foundry-client": "workspace:*", "@sandbox-agent/foundry-shared": "workspace:*", + "@sandbox-agent/react": "workspace:*", "@tanstack/react-query": "^5.85.5", "@tanstack/react-router": "^1.132.23", + "@tanstack/react-virtual": "^3.13.22", "baseui": "^16.1.1", "lucide-react": "^0.542.0", "react": "^19.1.1", diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index be995c0..e0f6803 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -391,20 +391,6 @@ const TranscriptPanel = memo(function TranscriptPanel({ textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; }, [draft, activeTabId, task.id]); - useEffect(() => { - if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) { - return; - } - - const targetNode = messageRefs.current.get(pendingHistoryTarget.messageId); - if (!targetNode) { - return; - } - - targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); - setPendingHistoryTarget(null); - }, [activeMessages.length, activeTabId, pendingHistoryTarget]); - useEffect(() => { if (!copiedMessageId) { return; @@ -694,13 +680,6 @@ const TranscriptPanel = memo(function TranscriptPanel({ if (activeTabId !== event.tabId) { switchTab(event.tabId); - return; - } - - const targetNode = messageRefs.current.get(event.messageId); - if (targetNode) { - targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); - setPendingHistoryTarget(null); } }, [activeTabId, switchTab], @@ -932,6 +911,8 @@ const TranscriptPanel = memo(function TranscriptPanel({ messageRefs={messageRefs} historyEvents={historyEvents} onSelectHistoryEvent={jumpToHistoryEvent} + targetMessageId={pendingHistoryTarget && activeTabId === pendingHistoryTarget.tabId ? pendingHistoryTarget.messageId : null} + onTargetMessageResolved={() => setPendingHistoryTarget(null)} copiedMessageId={copiedMessageId} onCopyMessage={(message) => { void copyMessage(message); @@ -1382,16 +1363,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never }); } }, [activeOrg, navigate]); - const [projectOrder, setProjectOrder] = useState(null); - const projects = useMemo(() => { - if (!projectOrder) return rawProjects; - const byId = new Map(rawProjects.map((p) => [p.id, p])); - const ordered = projectOrder.map((id) => byId.get(id)).filter(Boolean) as typeof rawProjects; - for (const p of rawProjects) { - if (!projectOrder.includes(p.id)) ordered.push(p); - } - return ordered; - }, [rawProjects, projectOrder]); + const projects = rawProjects; const [activeTabIdByTask, setActiveTabIdByTask] = useState>({}); const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState>({}); const [openDiffsByTask, setOpenDiffsByTask] = useState>({}); @@ -1418,30 +1390,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M peekTimeoutRef.current = setTimeout(() => setLeftSidebarPeeking(false), 200); }, []); - const reorderProjects = useCallback( - (fromIndex: number, toIndex: number) => { - const ids = projects.map((p) => p.id); - const [moved] = ids.splice(fromIndex, 1); - ids.splice(toIndex, 0, moved!); - setProjectOrder(ids); - }, - [projects], - ); - - const [taskOrderByProject, setTaskOrderByProject] = useState>({}); - const reorderTasks = useCallback( - (projectId: string, fromIndex: number, toIndex: number) => { - const project = projects.find((p) => p.id === projectId); - if (!project) return; - const currentOrder = taskOrderByProject[projectId] ?? project.tasks.map((t) => t.id); - const ids = [...currentOrder]; - const [moved] = ids.splice(fromIndex, 1); - ids.splice(toIndex, 0, moved!); - setTaskOrderByProject((prev) => ({ ...prev, [projectId]: ids })); - }, - [projects, taskOrderByProject], - ); - useEffect(() => { leftWidthRef.current = leftWidth; window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth)); @@ -1926,9 +1874,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M onMarkUnread={markTaskUnread} onRenameTask={renameTask} onRenameBranch={renameBranch} - onReorderProjects={reorderProjects} - taskOrderByProject={taskOrderByProject} - onReorderTasks={reorderTasks} onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} @@ -2101,9 +2046,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M onMarkUnread={markTaskUnread} onRenameTask={renameTask} onRenameBranch={renameBranch} - onReorderProjects={reorderProjects} - taskOrderByProject={taskOrderByProject} - onReorderTasks={reorderTasks} onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} @@ -2156,9 +2098,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M onMarkUnread={markTaskUnread} onRenameTask={renameTask} onRenameBranch={renameBranch} - onReorderProjects={reorderProjects} - taskOrderByProject={taskOrderByProject} - onReorderTasks={reorderTasks} onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} diff --git a/foundry/packages/frontend/src/components/mock-layout/message-list.tsx b/foundry/packages/frontend/src/components/mock-layout/message-list.tsx index 7068268..5fcb4f9 100644 --- a/foundry/packages/frontend/src/components/mock-layout/message-list.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/message-list.tsx @@ -1,5 +1,5 @@ import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react"; -import { memo, useMemo, type MutableRefObject, type Ref } from "react"; +import { memo, useEffect, useMemo, type MutableRefObject, type RefObject } from "react"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { Copy } from "lucide-react"; @@ -14,11 +14,15 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({ messageRefs, copiedMessageId, onCopyMessage, + isTarget, + onTargetRendered, }: { message: Message; messageRefs: MutableRefObject>; copiedMessageId: string | null; onCopyMessage: (message: Message) => void; + isTarget?: boolean; + onTargetRendered?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); @@ -27,6 +31,20 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({ const messageTimestamp = formatMessageTimestamp(message.createdAtMs); const displayFooter = isUser ? messageTimestamp : message.durationMs ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` : null; + useEffect(() => { + if (!isTarget) { + return; + } + + const targetNode = messageRefs.current.get(message.id); + if (!targetNode) { + return; + } + + targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); + onTargetRendered?.(); + }, [isTarget, message.id, messageRefs, onTargetRendered]); + return (
{ @@ -127,15 +145,19 @@ export const MessageList = memo(function MessageList({ messageRefs, historyEvents, onSelectHistoryEvent, + targetMessageId, + onTargetMessageResolved, copiedMessageId, onCopyMessage, thinkingTimerLabel, }: { tab: AgentTab | null | undefined; - scrollRef: Ref; + scrollRef: RefObject; messageRefs: MutableRefObject>; historyEvents: HistoryEvent[]; onSelectHistoryEvent: (event: HistoryEvent) => void; + targetMessageId?: string | null; + onTargetMessageResolved?: () => void; copiedMessageId: string | null; onCopyMessage: (message: Message) => void; thinkingTimerLabel: string | null; @@ -144,6 +166,7 @@ export const MessageList = memo(function MessageList({ const t = useFoundryTokens(); const messages = useMemo(() => buildDisplayMessages(tab), [tab]); const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]); + const messageIndexById = useMemo(() => new Map(messages.map((message, index) => [message.id, index])), [messages]); const transcriptEntries = useMemo( () => messages.map((message) => ({ @@ -192,6 +215,37 @@ export const MessageList = memo(function MessageList({ letterSpacing: "0.01em", }), }; + const scrollContainerClass = css({ + padding: "16px 52px 16px 20px", + display: "flex", + flexDirection: "column", + flex: 1, + minHeight: 0, + overflowY: "auto", + }); + + useEffect(() => { + if (!targetMessageId) { + return; + } + + const targetNode = messageRefs.current.get(targetMessageId); + if (targetNode) { + targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); + onTargetMessageResolved?.(); + return; + } + + const targetIndex = messageIndexById.get(targetMessageId); + if (targetIndex == null) { + return; + } + + scrollRef.current?.scrollTo({ + top: Math.max(0, targetIndex * 88), + behavior: "smooth", + }); + }, [messageIndexById, messageRefs, onTargetMessageResolved, scrollRef, targetMessageId]); return ( <> @@ -201,17 +255,7 @@ export const MessageList = memo(function MessageList({ } `} {historyEvents.length > 0 ? : null} -
+
{tab && transcriptEntries.length === 0 ? (
{ const message = messagesById.get(entry.id); if (!message) { return null; } - return ; + return ( + + ); }} isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)} renderThinkingState={() => ( diff --git a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx index 484619a..12f7ab9 100644 --- a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { Select, type Value } from "baseui/select"; @@ -68,9 +69,6 @@ export const Sidebar = memo(function Sidebar({ onMarkUnread, onRenameTask, onRenameBranch, - onReorderProjects, - taskOrderByProject, - onReorderTasks, onReloadOrganization, onReloadPullRequests, onReloadRepository, @@ -87,9 +85,6 @@ export const Sidebar = memo(function Sidebar({ onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; onRenameBranch: (id: string) => void; - onReorderProjects: (fromIndex: number, toIndex: number) => void; - taskOrderByProject: Record; - onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void; onReloadOrganization: () => void; onReloadPullRequests: () => void; onReloadRepository: (repoId: string) => void; @@ -103,66 +98,7 @@ export const Sidebar = memo(function Sidebar({ const [hoveredProjectId, setHoveredProjectId] = useState(null); const [headerMenuOpen, setHeaderMenuOpen] = useState(false); const headerMenuRef = useRef(null); - - // Mouse-based drag and drop state - type DragState = - | { type: "project"; fromIdx: number; overIdx: number | null } - | { type: "task"; projectId: string; fromIdx: number; overIdx: number | null } - | null; - const [drag, setDrag] = useState(null); - const dragRef = useRef(null); - const startYRef = useRef(0); - const didDragRef = useRef(false); - - // Attach global mousemove/mouseup when dragging - useEffect(() => { - if (!drag) return; - const onMove = (e: MouseEvent) => { - // Detect which element is under the cursor using data attributes - const el = document.elementFromPoint(e.clientX, e.clientY); - if (!el) return; - const projectEl = (el as HTMLElement).closest?.("[data-project-idx]") as HTMLElement | null; - const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null; - - if (drag.type === "project" && projectEl) { - const overIdx = Number(projectEl.dataset.projectIdx); - if (overIdx !== drag.overIdx) { - setDrag({ ...drag, overIdx }); - dragRef.current = { ...drag, overIdx }; - } - } else if (drag.type === "task" && taskEl) { - const overProjectId = taskEl.dataset.taskProjectId ?? ""; - const overIdx = Number(taskEl.dataset.taskIdx); - if (overProjectId === drag.projectId && overIdx !== drag.overIdx) { - setDrag({ ...drag, overIdx }); - dragRef.current = { ...drag, overIdx }; - } - } - // Mark that we actually moved (to distinguish from clicks) - if (Math.abs(e.clientY - startYRef.current) > 4) { - didDragRef.current = true; - } - }; - const onUp = () => { - const d = dragRef.current; - if (d && didDragRef.current && d.overIdx !== null && d.fromIdx !== d.overIdx) { - if (d.type === "project") { - onReorderProjects(d.fromIdx, d.overIdx); - } else { - onReorderTasks(d.projectId, d.fromIdx, d.overIdx); - } - } - dragRef.current = null; - didDragRef.current = false; - setDrag(null); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - return () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - }, [drag, onReorderProjects, onReorderTasks]); + const scrollRef = useRef(null); useEffect(() => { if (!headerMenuOpen) { @@ -180,6 +116,26 @@ export const Sidebar = memo(function Sidebar({ const [createSelectOpen, setCreateSelectOpen] = useState(false); const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]); + type FlatItem = { key: string; type: "project-header"; project: ProjectSection } | { key: string; type: "task"; project: ProjectSection; task: Task }; + const flatItems = useMemo( + () => + projects.flatMap((project) => { + const items: FlatItem[] = [{ key: `project:${project.id}`, type: "project-header", project }]; + if (!collapsedProjects[project.id]) { + items.push(...project.tasks.map((task) => ({ key: `task:${task.id}`, type: "task" as const, project, task }))); + } + return items; + }), + [collapsedProjects, projects], + ); + const virtualizer = useVirtualizer({ + count: flatItems.length, + getItemKey: (index) => flatItems[index]?.key ?? index, + getScrollElement: () => scrollRef.current, + estimateSize: () => 40, + overscan: 12, + measureElement: (element) => element.getBoundingClientRect().height, + }); return ( @@ -463,342 +419,270 @@ export const Sidebar = memo(function Sidebar({
)} - -
- {projects.map((project, projectIndex) => { - const isCollapsed = collapsedProjects[project.id] === true; - const isProjectDropTarget = drag?.type === "project" && drag.overIdx === projectIndex && drag.fromIdx !== projectIndex; - const isBeingDragged = drag?.type === "project" && drag.fromIdx === projectIndex && didDragRef.current; - const orderedTaskIds = taskOrderByProject[project.id]; - const orderedTasks = orderedTaskIds - ? (() => { - const byId = new Map(project.tasks.map((t) => [t.id, t])); - const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof project.tasks; - for (const t of project.tasks) { - if (!orderedTaskIds.includes(t.id)) sorted.push(t); - } - return sorted; - })() - : project.tasks; + +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = flatItems[virtualItem.index]; + if (!item) { + return null; + } - return ( -
-
setHoveredProjectId(project.id)} - onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))} - onMouseDown={(event) => { - if (event.button !== 0) return; - startYRef.current = event.clientY; - didDragRef.current = false; - setHoveredProjectId(null); - const state: DragState = { type: "project", fromIdx: projectIndex, overIdx: null }; - dragRef.current = state; - setDrag(state); - }} - onClick={() => { - if (!didDragRef.current) { - setCollapsedProjects((current) => ({ - ...current, - [project.id]: !current[project.id], - })); - } - }} - onContextMenu={(event) => - contextMenu.open(event, [ - { label: "Reload repository", onClick: () => onReloadRepository(project.id) }, - { label: "New task", onClick: () => onCreate(project.id) }, - ]) - } - data-project-header - className={css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "10px 8px 4px", - gap: "8px", - cursor: "grab", - userSelect: "none", - })} - > -
-
- - {projectInitial(project.label)} - - - {isCollapsed ? : } - -
- - {stripCommonOrgPrefix(project.label, projects)} - -
-
- {isCollapsed ? {formatRelativeAge(project.updatedAtMs)} : null} - -
-
+ if (item.type === "project-header") { + const project = item.project; + const isCollapsed = collapsedProjects[project.id] === true; - {!isCollapsed && - orderedTasks.map((task, taskIndex) => { - const isActive = task.id === activeId; - const isPullRequestItem = isPullRequestSidebarItem(task); - const isDim = task.status === "archived"; - const isRunning = task.tabs.some((tab) => tab.status === "running"); - const isProvisioning = - !isPullRequestItem && - (String(task.status).startsWith("init_") || - task.status === "new" || - task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create")); - const hasUnread = task.tabs.some((tab) => tab.unread); - const isDraft = task.pullRequest == null || task.pullRequest.status === "draft"; - const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0); - const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0); - const hasDiffs = totalAdded > 0 || totalRemoved > 0; - const isTaskDropTarget = drag?.type === "task" && drag.projectId === project.id && drag.overIdx === taskIndex && drag.fromIdx !== taskIndex; - const isTaskBeingDragged = drag?.type === "task" && drag.projectId === project.id && drag.fromIdx === taskIndex && didDragRef.current; - - return ( + return ( +
{ + if (node) { + virtualizer.measureElement(node); + } + }} + style={{ + left: 0, + position: "absolute", + top: 0, + transform: `translateY(${virtualItem.start}px)`, + width: "100%", + }} + > +
{ - if (event.button !== 0) return; - // Only start task drag if not already in a project drag - if (dragRef.current) return; - event.stopPropagation(); - startYRef.current = event.clientY; - didDragRef.current = false; - const state: DragState = { type: "task", projectId: project.id, fromIdx: taskIndex, overIdx: null }; - dragRef.current = state; - setDrag(state); - }} + onMouseEnter={() => setHoveredProjectId(project.id)} + onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))} onClick={() => { - if (!didDragRef.current) { - onSelect(task.id); - } + setCollapsedProjects((current) => ({ + ...current, + [project.id]: !current[project.id], + })); }} - onContextMenu={(event) => { - if (isPullRequestItem && task.pullRequest) { - contextMenu.open(event, [ - { label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) }, - { label: "Create task", onClick: () => onSelect(task.id) }, - ]); - return; - } + onContextMenu={(event) => contextMenu.open(event, [ - { label: "Rename task", onClick: () => onRenameTask(task.id) }, - { label: "Rename branch", onClick: () => onRenameBranch(task.id) }, - { label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, - ]); - }} + { label: "Reload repository", onClick: () => onReloadRepository(project.id) }, + { label: "New task", onClick: () => onCreate(project.id) }, + ]) + } + data-project-header className={css({ - padding: "8px 12px", - borderRadius: "8px", - position: "relative", - backgroundColor: isActive ? t.interactiveHover : "transparent", - opacity: isTaskBeingDragged ? 0.4 : 1, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "10px 8px 4px", + gap: "8px", cursor: "pointer", - transition: "all 150ms ease", - "::before": { - content: '""', - position: "absolute", - top: "-2px", - left: 0, - right: 0, - height: "2px", - backgroundColor: isTaskDropTarget ? t.textPrimary : "transparent", - transition: "background-color 100ms ease", - }, - ":hover": { - backgroundColor: t.interactiveHover, - }, + userSelect: "none", })} > -
-
+
+ + {projectInitial(project.label)} + + + {isCollapsed ? : } + +
+ + {stripCommonOrgPrefix(project.label, projects)} + +
+
+ {isCollapsed ? {formatRelativeAge(project.updatedAtMs)} : null} +
-
- - {task.title} - - {isPullRequestItem && task.statusMessage ? ( - - {task.statusMessage} - - ) : null} -
- {task.pullRequest != null ? ( - - - #{task.pullRequest.number} - - {task.pullRequest.status === "draft" ? : null} - - ) : ( - - )} - {hasDiffs ? ( -
- +{totalAdded} - -{totalRemoved} -
- ) : null} - - {formatRelativeAge(task.updatedAtMs)} - + +
- ); - })} - {/* Bottom drop zone for dragging to end of task list */} - {!isCollapsed && ( -
- )} -
- ); - })} - {/* Bottom drop zone for dragging project to end of list */} -
+
+ ); + } + + const { project, task } = item; + const isActive = task.id === activeId; + const isPullRequestItem = isPullRequestSidebarItem(task); + const isRunning = task.tabs.some((tab) => tab.status === "running"); + const isProvisioning = + !isPullRequestItem && + (String(task.status).startsWith("init_") || + task.status === "new" || + task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create")); + const hasUnread = task.tabs.some((tab) => tab.unread); + const isDraft = task.pullRequest == null || task.pullRequest.status === "draft"; + const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0); + const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0); + const hasDiffs = totalAdded > 0 || totalRemoved > 0; + + return ( +
{ + if (node) { + virtualizer.measureElement(node); + } + }} + style={{ + left: 0, + position: "absolute", + top: 0, + transform: `translateY(${virtualItem.start}px)`, + width: "100%", + }} + > +
+
onSelect(task.id)} + onContextMenu={(event) => { + if (isPullRequestItem && task.pullRequest) { + contextMenu.open(event, [ + { label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) }, + { label: "Create task", onClick: () => onSelect(task.id) }, + ]); + return; + } + contextMenu.open(event, [ + { label: "Rename task", onClick: () => onRenameTask(task.id) }, + { label: "Rename branch", onClick: () => onRenameBranch(task.id) }, + { label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, + ]); + }} + className={css({ + padding: "8px 12px", + borderRadius: "8px", + backgroundColor: isActive ? t.interactiveHover : "transparent", + cursor: "pointer", + transition: "all 150ms ease", + ":hover": { + backgroundColor: t.interactiveHover, + }, + })} + > +
+
+ {isPullRequestItem ? ( + + ) : ( + + )} +
+
+ + {task.title} + + {isPullRequestItem && task.statusMessage ? ( + + {task.statusMessage} + + ) : null} +
+ {task.pullRequest != null ? ( + + + #{task.pullRequest.number} + + {task.pullRequest.status === "draft" ? : null} + + ) : ( + + )} + {hasDiffs ? ( +
+ +{totalAdded} + -{totalRemoved} +
+ ) : null} + + {formatRelativeAge(task.updatedAtMs)} + +
+
+
+
+ ); })} - /> +
diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index a829ae6..ac06904 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -286,7 +286,7 @@ export default function App() { const [highlightedEventId, setHighlightedEventId] = useState(null); const [debugPanelCollapsed, setDebugPanelCollapsed] = useState(false); - const messagesEndRef = useRef(null); + const transcriptScrollRef = useRef(null); const clientRef = useRef(null); const activeSessionRef = useRef(null); @@ -1434,10 +1434,6 @@ export default function App() { }); }, [connected, sessionId, sessions, getClient, subscribeToSession]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [transcriptEntries]); - const currentAgent = agents.find((agent) => agent.id === agentId); const agentLabel = agentDisplayNames[agentId] ?? agentId; const selectedSession = sessions.find((s) => s.sessionId === sessionId); @@ -1743,7 +1739,7 @@ export default function App() { } agentsLoading={agentsLoading} agentsError={agentsError} - messagesEndRef={messagesEndRef} + scrollRef={transcriptScrollRef} agentLabel={agentLabel} modelLabel={modelPillLabel} currentAgentVersion={currentAgent?.version ?? null} diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 5d77a93..5afc259 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -1,6 +1,6 @@ import type { TranscriptEntry } from "@sandbox-agent/react"; import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import type { AgentInfo } from "sandbox-agent"; import { formatShortId } from "../../utils/format"; @@ -40,7 +40,7 @@ const ChatPanel = ({ agents, agentsLoading, agentsError, - messagesEndRef, + scrollRef, agentLabel, modelLabel, currentAgentVersion, @@ -71,7 +71,7 @@ const ChatPanel = ({ agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; - messagesEndRef: React.RefObject; + scrollRef: RefObject; agentLabel: string; modelLabel?: string | null; currentAgentVersion?: string | null; @@ -233,7 +233,7 @@ const ChatPanel = ({ entries={transcriptEntries} sessionError={sessionError} eventError={null} - messagesEndRef={messagesEndRef} + scrollRef={scrollRef} onEventClick={onEventClick} isThinking={isThinking} agentId={agentId} diff --git a/frontend/packages/inspector/src/components/chat/InspectorConversation.tsx b/frontend/packages/inspector/src/components/chat/InspectorConversation.tsx index cb3c1af..5d3c007 100644 --- a/frontend/packages/inspector/src/components/chat/InspectorConversation.tsx +++ b/frontend/packages/inspector/src/components/chat/InspectorConversation.tsx @@ -7,7 +7,7 @@ import { type TranscriptEntry, } from "@sandbox-agent/react"; import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } from "lucide-react"; -import type { ReactNode } from "react"; +import type { ReactNode, RefObject } from "react"; import MarkdownText from "./MarkdownText"; const agentLogos: Record = { @@ -84,7 +84,7 @@ export interface InspectorConversationProps { entries: TranscriptEntry[]; sessionError: string | null; eventError?: string | null; - messagesEndRef: React.RefObject; + scrollRef: RefObject; onEventClick?: (eventId: string) => void; isThinking?: boolean; agentId?: string; @@ -102,7 +102,7 @@ const InspectorConversation = ({ entries, sessionError, eventError, - messagesEndRef, + scrollRef, onEventClick, isThinking, agentId, @@ -119,12 +119,13 @@ const InspectorConversation = ({ =18.0.0'} cpu: [arm64] os: [darwin] - '@boxlite-ai/boxlite-linux-x64-gnu@0.4.2': - resolution: {integrity: sha512-UIRiTKl1L0cx2igDiikEiBfpNbTZ0W3lft5ow7I2mkDnjtBVIQYSm+PmVXBupTYivAuPh38g9WhqJH44C1RJdQ==} + '@boxlite-ai/boxlite-linux-x64-gnu@0.4.3': + resolution: {integrity: sha512-e5Ukl2pyqFe046cA+VcDUL9iso1OseHS13BEDnr/ADKsG+P//bYZHnE0JZPJL1ai4+fHg6d6BOe113rOxba1eQ==} engines: {node: '>=18.0.0'} cpu: [x64] os: [linux] - '@boxlite-ai/boxlite@0.4.2': - resolution: {integrity: sha512-LVxG0feP1sBGbYz/VOm11VsU8PyUv7rvXOQJqKrfBgI9oRVyqycpY39PCJ1oC+FFho7w7d61q8VCVDlDdj8i6Q==} + '@boxlite-ai/boxlite@0.4.3': + resolution: {integrity: sha512-bCYSrJH8mAlz+JoyVkCUSfYuCp2IwqaLrvOu4m1vstq6LNwkLcpmJzs9gLXrHnYb+YitYko3pQiK8uTieG4BJw==} engines: {node: '>=18.0.0'} peerDependencies: playwright-core: '>=1.58.0' @@ -3642,6 +3648,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.22': + resolution: {integrity: sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.166.4': resolution: {integrity: sha512-T/RrsAvznqNJqfT7nrj3S+/RiQmW4U/i4Vii8KdOQdhahPzAQnmRzZB+SUwR4quqRYql5o2zmCA6Brg1961hHg==} engines: {node: '>=20.19'} @@ -3649,6 +3661,9 @@ packages: '@tanstack/store@0.9.2': resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@tanstack/virtual-core@3.13.22': + resolution: {integrity: sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -8419,16 +8434,16 @@ snapshots: '@biomejs/cli-win32-x64@2.4.6': optional: true - '@boxlite-ai/boxlite-darwin-arm64@0.4.2': + '@boxlite-ai/boxlite-darwin-arm64@0.4.3': optional: true - '@boxlite-ai/boxlite-linux-x64-gnu@0.4.2': + '@boxlite-ai/boxlite-linux-x64-gnu@0.4.3': optional: true - '@boxlite-ai/boxlite@0.4.2': + '@boxlite-ai/boxlite@0.4.3': optionalDependencies: - '@boxlite-ai/boxlite-darwin-arm64': 0.4.2 - '@boxlite-ai/boxlite-linux-x64-gnu': 0.4.2 + '@boxlite-ai/boxlite-darwin-arm64': 0.4.3 + '@boxlite-ai/boxlite-linux-x64-gnu': 0.4.3 '@bufbuild/protobuf@2.11.0': {} @@ -10246,6 +10261,18 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) + '@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.22 + react: 18.3.1 + react-dom: 19.2.4(react@18.3.1) + + '@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.13.22 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@tanstack/router-core@1.166.4': dependencies: '@tanstack/history': 1.161.4 @@ -10258,6 +10285,8 @@ snapshots: '@tanstack/store@0.9.2': {} + '@tanstack/virtual-core@3.13.22': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -13238,6 +13267,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@19.2.4(react@18.3.1): + dependencies: + react: 18.3.1 + scheduler: 0.27.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/sdks/react/package.json b/sdks/react/package.json index 18bede0..8b2e1d4 100644 --- a/sdks/react/package.json +++ b/sdks/react/package.json @@ -28,6 +28,7 @@ "sandbox-agent": "^0.2.2" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.22", "ghostty-web": "^0.4.0" }, "devDependencies": { diff --git a/sdks/react/src/AgentConversation.tsx b/sdks/react/src/AgentConversation.tsx index 632fac4..acc9466 100644 --- a/sdks/react/src/AgentConversation.tsx +++ b/sdks/react/src/AgentConversation.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import type { ReactNode, RefObject } from "react"; import { AgentTranscript, type AgentTranscriptClassNames, type AgentTranscriptProps, type TranscriptEntry } from "./AgentTranscript.tsx"; import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx"; @@ -18,9 +18,10 @@ export interface AgentConversationProps { emptyState?: ReactNode; transcriptClassName?: string; transcriptClassNames?: Partial; + scrollRef?: RefObject; composerClassName?: string; composerClassNames?: Partial; - transcriptProps?: Omit; + transcriptProps?: Omit; composerProps?: Omit; } @@ -47,6 +48,7 @@ export const AgentConversation = ({ emptyState, transcriptClassName, transcriptClassNames, + scrollRef, composerClassName, composerClassNames, transcriptProps, @@ -58,12 +60,18 @@ export const AgentConversation = ({ return (
{hasTranscriptContent ? ( - + scrollRef ? ( +
+ +
+ ) : ( + + ) ) : emptyState ? (
{emptyState} diff --git a/sdks/react/src/AgentTranscript.tsx b/sdks/react/src/AgentTranscript.tsx index f884dd6..b565081 100644 --- a/sdks/react/src/AgentTranscript.tsx +++ b/sdks/react/src/AgentTranscript.tsx @@ -1,7 +1,8 @@ "use client"; import type { ReactNode, RefObject } from "react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts"; export type PermissionReply = "once" | "always" | "reject"; @@ -98,10 +99,14 @@ export interface AgentTranscriptProps { className?: string; classNames?: Partial; endRef?: RefObject; + scrollRef?: RefObject; + scrollToEntryId?: string | null; sessionError?: string | null; eventError?: string | null; isThinking?: boolean; agentId?: string; + virtualize?: boolean; + onAtBottomChange?: (atBottom: boolean) => void; onEventClick?: (eventId: string) => void; onPermissionReply?: (permissionId: string, reply: PermissionReply) => void; isDividerEntry?: (entry: TranscriptEntry) => boolean; @@ -124,6 +129,8 @@ type GroupedEntries = | { type: "divider"; entries: TranscriptEntry[] } | { type: "permission"; entries: TranscriptEntry[] }; +const VIRTUAL_GROUP_GAP_PX = 12; + const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = { root: "sa-agent-transcript", divider: "sa-agent-transcript-divider", @@ -324,9 +331,21 @@ const buildGroupedEntries = (entries: TranscriptEntry[], isDividerEntry: (entry: return groupedEntries; }; +const getGroupedEntryKey = (group: GroupedEntries, index: number): string => { + const firstEntry = group.entries[0]; + + if (group.type === "tool-group") { + return `tool-group:${firstEntry?.id ?? index}`; + } + + return firstEntry?.id ?? `${group.type}:${index}`; +}; + const ToolItem = ({ entry, isLast, + expanded, + onExpandedChange, classNames, onEventClick, canOpenEvent, @@ -337,6 +356,8 @@ const ToolItem = ({ }: { entry: TranscriptEntry; isLast: boolean; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; classNames: AgentTranscriptClassNames; onEventClick?: (eventId: string) => void; canOpenEvent: (entry: TranscriptEntry) => boolean; @@ -345,7 +366,6 @@ const ToolItem = ({ renderChevron: (expanded: boolean) => ReactNode; renderEventLinkContent: (entry: TranscriptEntry) => ReactNode; }) => { - const [expanded, setExpanded] = useState(false); const isTool = entry.kind === "tool"; const isReasoning = entry.kind === "reasoning"; const isMeta = entry.kind === "meta"; @@ -382,7 +402,7 @@ const ToolItem = ({ disabled={!hasContent} onClick={() => { if (hasContent) { - setExpanded((value) => !value); + onExpandedChange(!expanded); } }} > @@ -469,6 +489,10 @@ const ToolItem = ({ const ToolGroup = ({ entries, + expanded, + onExpandedChange, + expandedItemIds, + onToolItemExpandedChange, classNames, onEventClick, canOpenEvent, @@ -480,6 +504,10 @@ const ToolGroup = ({ renderEventLinkContent, }: { entries: TranscriptEntry[]; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; + expandedItemIds: Record; + onToolItemExpandedChange: (entryId: string, expanded: boolean) => void; classNames: AgentTranscriptClassNames; onEventClick?: (eventId: string) => void; canOpenEvent: (entry: TranscriptEntry) => boolean; @@ -490,7 +518,6 @@ const ToolGroup = ({ renderChevron: (expanded: boolean) => ReactNode; renderEventLinkContent: (entry: TranscriptEntry) => ReactNode; }) => { - const [expanded, setExpanded] = useState(false); const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed"); if (entries.length === 1) { @@ -499,6 +526,8 @@ const ToolGroup = ({ onToolItemExpandedChange(entries[0]!.id, nextExpanded)} classNames={classNames} onEventClick={onEventClick} canOpenEvent={canOpenEvent} @@ -518,7 +547,7 @@ const ToolGroup = ({ className={cx(classNames.toolGroupHeader, expanded && "expanded")} data-slot="tool-group-header" data-expanded={expanded ? "true" : undefined} - onClick={() => setExpanded((value) => !value)} + onClick={() => onExpandedChange(!expanded)} > {renderToolGroupIcon(entries, expanded)} @@ -537,6 +566,8 @@ const ToolGroup = ({ key={entry.id} entry={entry} isLast={index === entries.length - 1} + expanded={Boolean(expandedItemIds[entry.id])} + onExpandedChange={(nextExpanded) => onToolItemExpandedChange(entry.id, nextExpanded)} classNames={classNames} onEventClick={onEventClick} canOpenEvent={canOpenEvent} @@ -636,10 +667,14 @@ export const AgentTranscript = ({ className, classNames: classNameOverrides, endRef, + scrollRef, + scrollToEntryId, sessionError, eventError, isThinking, agentId, + virtualize = false, + onAtBottomChange, onEventClick, onPermissionReply, isDividerEntry = defaultIsDividerEntry, @@ -657,83 +692,199 @@ export const AgentTranscript = ({ }: AgentTranscriptProps) => { const resolvedClassNames = useMemo(() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides), [classNameOverrides]); const groupedEntries = useMemo(() => buildGroupedEntries(entries, isDividerEntry), [entries, isDividerEntry]); + const [expandedToolGroups, setExpandedToolGroups] = useState>({}); + const [expandedToolItems, setExpandedToolItems] = useState>({}); + const lastScrollTargetRef = useRef(null); + const isVirtualized = virtualize && Boolean(scrollRef); + const { virtualizer, isFollowingRef } = useTranscriptVirtualizer(groupedEntries, isVirtualized ? scrollRef : undefined, onAtBottomChange); + + useEffect(() => { + if (!scrollToEntryId) { + lastScrollTargetRef.current = null; + return; + } + + if (!isVirtualized || scrollToEntryId === lastScrollTargetRef.current) { + return; + } + + const targetIndex = groupedEntries.findIndex((group) => group.entries.some((entry) => entry.id === scrollToEntryId)); + if (targetIndex < 0) { + return; + } + + lastScrollTargetRef.current = scrollToEntryId; + + const frameId = requestAnimationFrame(() => { + virtualizer.scrollToIndex(targetIndex, { + align: "center", + behavior: "smooth", + }); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [groupedEntries, isVirtualized, scrollToEntryId, virtualizer]); + + useEffect(() => { + if (!isVirtualized || !scrollRef?.current || !isFollowingRef.current) { + return; + } + + const scrollElement = scrollRef.current; + const frameId = requestAnimationFrame(() => { + scrollElement.scrollTo({ top: scrollElement.scrollHeight }); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [eventError, isFollowingRef, isThinking, isVirtualized, scrollRef, sessionError]); + + const setToolGroupExpanded = (groupKey: string, expanded: boolean) => { + setExpandedToolGroups((current) => { + if (current[groupKey] === expanded) { + return current; + } + return { ...current, [groupKey]: expanded }; + }); + }; + + const setToolItemExpanded = (entryId: string, expanded: boolean) => { + setExpandedToolItems((current) => { + if (current[entryId] === expanded) { + return current; + } + return { ...current, [entryId]: expanded }; + }); + }; + + const renderGroup = (group: GroupedEntries, index: number) => { + if (group.type === "divider") { + const entry = group.entries[0]; + const title = entry.meta?.title ?? "Status"; + return ( +
+
+ + {title} + +
+
+ ); + } + + if (group.type === "tool-group") { + const groupKey = getGroupedEntryKey(group, index); + + return ( + setToolGroupExpanded(groupKey, expanded)} + expandedItemIds={expandedToolItems} + onToolItemExpandedChange={setToolItemExpanded} + classNames={resolvedClassNames} + onEventClick={onEventClick} + canOpenEvent={canOpenEvent} + getToolGroupSummary={getToolGroupSummary} + renderInlinePendingIndicator={renderInlinePendingIndicator} + renderToolItemIcon={renderToolItemIcon} + renderToolGroupIcon={renderToolGroupIcon} + renderChevron={renderChevron} + renderEventLinkContent={renderEventLinkContent} + /> + ); + } + + if (group.type === "permission") { + const entry = group.entries[0]; + return ( + + ); + } + + const entry = group.entries[0]; + const messageVariant = getMessageVariant(entry); + + return ( +
+
+ {entry.text ? ( +
+ {renderMessageText(entry)} +
+ ) : ( + + {renderInlinePendingIndicator()} + + )} +
+
+ ); + }; return ( -
- {groupedEntries.map((group, index) => { - if (group.type === "divider") { - const entry = group.entries[0]; - const title = entry.meta?.title ?? "Status"; - return ( -
-
- - {title} - -
-
- ); - } +
+ {isVirtualized ? ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const group = groupedEntries[virtualItem.index]; + if (!group) { + return null; + } - if (group.type === "tool-group") { - return ( - - ); - } - - if (group.type === "permission") { - const entry = group.entries[0]; - return ( - - ); - } - - const entry = group.entries[0]; - const messageVariant = getMessageVariant(entry); - - return ( -
-
- {entry.text ? ( -
- {renderMessageText(entry)} + return ( +
{ + if (node) { + virtualizer.measureElement(node); + } + }} + style={{ + left: 0, + position: "absolute", + top: 0, + transform: `translateY(${virtualItem.start}px)`, + width: "100%", + }} + > +
+ {renderGroup(group, virtualItem.index)}
- ) : ( - - {renderInlinePendingIndicator()} - - )} -
-
- ); - })} +
+ ); + })} +
+ ) : ( + groupedEntries.map((group, index) => renderGroup(group, index)) + )} {sessionError ? (
{sessionError} @@ -753,7 +904,7 @@ export const AgentTranscript = ({
)) : null} -
+ {!isVirtualized ?
: null}
); }; diff --git a/sdks/react/src/index.ts b/sdks/react/src/index.ts index f76f2a3..55d4a91 100644 --- a/sdks/react/src/index.ts +++ b/sdks/react/src/index.ts @@ -2,6 +2,7 @@ export { AgentConversation } from "./AgentConversation.tsx"; export { AgentTranscript } from "./AgentTranscript.tsx"; export { ChatComposer } from "./ChatComposer.tsx"; export { ProcessTerminal } from "./ProcessTerminal.tsx"; +export { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts"; export type { AgentConversationClassNames, diff --git a/sdks/react/src/useTranscriptVirtualizer.ts b/sdks/react/src/useTranscriptVirtualizer.ts new file mode 100644 index 0000000..dc52717 --- /dev/null +++ b/sdks/react/src/useTranscriptVirtualizer.ts @@ -0,0 +1,58 @@ +"use client"; + +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +export function useTranscriptVirtualizer(items: T[], scrollElementRef?: RefObject, onAtBottomChange?: (atBottom: boolean) => void) { + const isFollowingRef = useRef(true); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollElementRef?.current ?? null, + estimateSize: () => 80, + measureElement: (element) => element.getBoundingClientRect().height, + overscan: 10, + }); + + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => isFollowingRef.current; + + useEffect(() => { + const scrollElement = scrollElementRef?.current; + if (!scrollElement) { + return; + } + + const updateFollowState = () => { + const atBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight < 50; + isFollowingRef.current = atBottom; + onAtBottomChange?.(atBottom); + }; + + updateFollowState(); + scrollElement.addEventListener("scroll", updateFollowState, { passive: true }); + + return () => { + scrollElement.removeEventListener("scroll", updateFollowState); + }; + }, [onAtBottomChange, scrollElementRef]); + + useEffect(() => { + if (!isFollowingRef.current || items.length === 0) { + return; + } + + const frameId = requestAnimationFrame(() => { + virtualizer.scrollToIndex(items.length - 1, { + align: "end", + behavior: "smooth", + }); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [items.length, virtualizer]); + + return { virtualizer, isFollowingRef }; +}