mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 11:04:58 +00:00
Add transcript virtualization to Foundry UI
This commit is contained in:
parent
5ea9ec5e2f
commit
070a5f1cd6
15 changed files with 780 additions and 576 deletions
|
|
@ -12,6 +12,7 @@ Current exports:
|
||||||
- `ProcessTerminal` for attaching to a running tty process
|
- `ProcessTerminal` for attaching to a running tty process
|
||||||
- `AgentTranscript` for rendering session/message timelines without bundling any styles
|
- `AgentTranscript` for rendering session/message timelines without bundling any styles
|
||||||
- `ChatComposer` for a reusable prompt input/send surface
|
- `ChatComposer` for a reusable prompt input/send surface
|
||||||
|
- `useTranscriptVirtualizer` for wiring large transcript lists to a scroll container
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|
@ -184,11 +185,20 @@ Useful props:
|
||||||
|
|
||||||
- `className`: root class hook
|
- `className`: root class hook
|
||||||
- `classNames`: slot-level class hooks for styling from outside the package
|
- `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
|
- `renderMessageText`: custom text or markdown renderer
|
||||||
- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides
|
- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides
|
||||||
- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides
|
- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides
|
||||||
- `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels
|
- `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
|
## 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.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,41 @@ function minutesAgo(minutes: number): number {
|
||||||
return NOW_MS - minutes * 60_000;
|
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[] {
|
export function parseDiffLines(diff: string): ParsedDiffLine[] {
|
||||||
return diff.split("\n").map((text, index) => {
|
return diff.split("\n").map((text, index) => {
|
||||||
if (text.startsWith("@@")) {
|
if (text.startsWith("@@")) {
|
||||||
|
|
@ -1189,6 +1224,35 @@ export function buildInitialTasks(): Task[] {
|
||||||
fileTree: [],
|
fileTree: [],
|
||||||
minutesUsed: 0,
|
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",
|
id: "status-running",
|
||||||
repoId: "sandbox-agent",
|
repoId: "sandbox-agent",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sandbox-agent/react": "workspace:*",
|
|
||||||
"@sandbox-agent/foundry-client": "workspace:*",
|
"@sandbox-agent/foundry-client": "workspace:*",
|
||||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||||
|
"@sandbox-agent/react": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"@tanstack/react-router": "^1.132.23",
|
"@tanstack/react-router": "^1.132.23",
|
||||||
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"baseui": "^16.1.1",
|
"baseui": "^16.1.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|
|
||||||
|
|
@ -391,20 +391,6 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
|
textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
|
||||||
}, [draft, activeTabId, task.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!copiedMessageId) {
|
if (!copiedMessageId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -694,13 +680,6 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
|
|
||||||
if (activeTabId !== event.tabId) {
|
if (activeTabId !== event.tabId) {
|
||||||
switchTab(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],
|
[activeTabId, switchTab],
|
||||||
|
|
@ -932,6 +911,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
messageRefs={messageRefs}
|
messageRefs={messageRefs}
|
||||||
historyEvents={historyEvents}
|
historyEvents={historyEvents}
|
||||||
onSelectHistoryEvent={jumpToHistoryEvent}
|
onSelectHistoryEvent={jumpToHistoryEvent}
|
||||||
|
targetMessageId={pendingHistoryTarget && activeTabId === pendingHistoryTarget.tabId ? pendingHistoryTarget.messageId : null}
|
||||||
|
onTargetMessageResolved={() => setPendingHistoryTarget(null)}
|
||||||
copiedMessageId={copiedMessageId}
|
copiedMessageId={copiedMessageId}
|
||||||
onCopyMessage={(message) => {
|
onCopyMessage={(message) => {
|
||||||
void copyMessage(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 });
|
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
|
||||||
}
|
}
|
||||||
}, [activeOrg, navigate]);
|
}, [activeOrg, navigate]);
|
||||||
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
|
const projects = rawProjects;
|
||||||
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 [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
|
const [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
|
||||||
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
|
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
|
||||||
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
|
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
|
||||||
|
|
@ -1418,30 +1390,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
peekTimeoutRef.current = setTimeout(() => setLeftSidebarPeeking(false), 200);
|
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<Record<string, string[]>>({});
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
leftWidthRef.current = leftWidth;
|
leftWidthRef.current = leftWidth;
|
||||||
window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth));
|
window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth));
|
||||||
|
|
@ -1926,9 +1874,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onMarkUnread={markTaskUnread}
|
onMarkUnread={markTaskUnread}
|
||||||
onRenameTask={renameTask}
|
onRenameTask={renameTask}
|
||||||
onRenameBranch={renameBranch}
|
onRenameBranch={renameBranch}
|
||||||
onReorderProjects={reorderProjects}
|
|
||||||
taskOrderByProject={taskOrderByProject}
|
|
||||||
onReorderTasks={reorderTasks}
|
|
||||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
|
@ -2101,9 +2046,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onMarkUnread={markTaskUnread}
|
onMarkUnread={markTaskUnread}
|
||||||
onRenameTask={renameTask}
|
onRenameTask={renameTask}
|
||||||
onRenameBranch={renameBranch}
|
onRenameBranch={renameBranch}
|
||||||
onReorderProjects={reorderProjects}
|
|
||||||
taskOrderByProject={taskOrderByProject}
|
|
||||||
onReorderTasks={reorderTasks}
|
|
||||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
|
@ -2156,9 +2098,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onMarkUnread={markTaskUnread}
|
onMarkUnread={markTaskUnread}
|
||||||
onRenameTask={renameTask}
|
onRenameTask={renameTask}
|
||||||
onRenameBranch={renameBranch}
|
onRenameBranch={renameBranch}
|
||||||
onReorderProjects={reorderProjects}
|
|
||||||
taskOrderByProject={taskOrderByProject}
|
|
||||||
onReorderTasks={reorderTasks}
|
|
||||||
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react";
|
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 { useStyletron } from "baseui";
|
||||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
|
|
@ -14,11 +14,15 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
||||||
messageRefs,
|
messageRefs,
|
||||||
copiedMessageId,
|
copiedMessageId,
|
||||||
onCopyMessage,
|
onCopyMessage,
|
||||||
|
isTarget,
|
||||||
|
onTargetRendered,
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
copiedMessageId: string | null;
|
copiedMessageId: string | null;
|
||||||
onCopyMessage: (message: Message) => void;
|
onCopyMessage: (message: Message) => void;
|
||||||
|
isTarget?: boolean;
|
||||||
|
onTargetRendered?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [css] = useStyletron();
|
const [css] = useStyletron();
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
|
|
@ -27,6 +31,20 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
||||||
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
|
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
|
||||||
const displayFooter = isUser ? messageTimestamp : message.durationMs ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` : null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
|
|
@ -127,15 +145,19 @@ export const MessageList = memo(function MessageList({
|
||||||
messageRefs,
|
messageRefs,
|
||||||
historyEvents,
|
historyEvents,
|
||||||
onSelectHistoryEvent,
|
onSelectHistoryEvent,
|
||||||
|
targetMessageId,
|
||||||
|
onTargetMessageResolved,
|
||||||
copiedMessageId,
|
copiedMessageId,
|
||||||
onCopyMessage,
|
onCopyMessage,
|
||||||
thinkingTimerLabel,
|
thinkingTimerLabel,
|
||||||
}: {
|
}: {
|
||||||
tab: AgentTab | null | undefined;
|
tab: AgentTab | null | undefined;
|
||||||
scrollRef: Ref<HTMLDivElement>;
|
scrollRef: RefObject<HTMLDivElement>;
|
||||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
historyEvents: HistoryEvent[];
|
historyEvents: HistoryEvent[];
|
||||||
onSelectHistoryEvent: (event: HistoryEvent) => void;
|
onSelectHistoryEvent: (event: HistoryEvent) => void;
|
||||||
|
targetMessageId?: string | null;
|
||||||
|
onTargetMessageResolved?: () => void;
|
||||||
copiedMessageId: string | null;
|
copiedMessageId: string | null;
|
||||||
onCopyMessage: (message: Message) => void;
|
onCopyMessage: (message: Message) => void;
|
||||||
thinkingTimerLabel: string | null;
|
thinkingTimerLabel: string | null;
|
||||||
|
|
@ -144,6 +166,7 @@ export const MessageList = memo(function MessageList({
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
const messages = useMemo(() => buildDisplayMessages(tab), [tab]);
|
const messages = useMemo(() => buildDisplayMessages(tab), [tab]);
|
||||||
const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]);
|
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<TranscriptEntry[]>(
|
const transcriptEntries = useMemo<TranscriptEntry[]>(
|
||||||
() =>
|
() =>
|
||||||
messages.map((message) => ({
|
messages.map((message) => ({
|
||||||
|
|
@ -192,6 +215,37 @@ export const MessageList = memo(function MessageList({
|
||||||
letterSpacing: "0.01em",
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -201,17 +255,7 @@ export const MessageList = memo(function MessageList({
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
|
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
|
||||||
<div
|
<div ref={scrollRef} className={scrollContainerClass}>
|
||||||
ref={scrollRef}
|
|
||||||
className={css({
|
|
||||||
padding: "16px 52px 16px 20px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: "auto",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{tab && transcriptEntries.length === 0 ? (
|
{tab && transcriptEntries.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -232,13 +276,25 @@ export const MessageList = memo(function MessageList({
|
||||||
<AgentTranscript
|
<AgentTranscript
|
||||||
entries={transcriptEntries}
|
entries={transcriptEntries}
|
||||||
classNames={transcriptClassNames}
|
classNames={transcriptClassNames}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
scrollToEntryId={targetMessageId}
|
||||||
|
virtualize
|
||||||
renderMessageText={(entry) => {
|
renderMessageText={(entry) => {
|
||||||
const message = messagesById.get(entry.id);
|
const message = messagesById.get(entry.id);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TranscriptMessageBody message={message} messageRefs={messageRefs} copiedMessageId={copiedMessageId} onCopyMessage={onCopyMessage} />;
|
return (
|
||||||
|
<TranscriptMessageBody
|
||||||
|
message={message}
|
||||||
|
messageRefs={messageRefs}
|
||||||
|
copiedMessageId={copiedMessageId}
|
||||||
|
onCopyMessage={onCopyMessage}
|
||||||
|
isTarget={targetMessageId === entry.id}
|
||||||
|
onTargetRendered={onTargetMessageResolved}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
|
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
|
||||||
renderThinkingState={() => (
|
renderThinkingState={() => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||||
import { Select, type Value } from "baseui/select";
|
import { Select, type Value } from "baseui/select";
|
||||||
|
|
@ -68,9 +69,6 @@ export const Sidebar = memo(function Sidebar({
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
onRenameTask,
|
onRenameTask,
|
||||||
onRenameBranch,
|
onRenameBranch,
|
||||||
onReorderProjects,
|
|
||||||
taskOrderByProject,
|
|
||||||
onReorderTasks,
|
|
||||||
onReloadOrganization,
|
onReloadOrganization,
|
||||||
onReloadPullRequests,
|
onReloadPullRequests,
|
||||||
onReloadRepository,
|
onReloadRepository,
|
||||||
|
|
@ -87,9 +85,6 @@ export const Sidebar = memo(function Sidebar({
|
||||||
onMarkUnread: (id: string) => void;
|
onMarkUnread: (id: string) => void;
|
||||||
onRenameTask: (id: string) => void;
|
onRenameTask: (id: string) => void;
|
||||||
onRenameBranch: (id: string) => void;
|
onRenameBranch: (id: string) => void;
|
||||||
onReorderProjects: (fromIndex: number, toIndex: number) => void;
|
|
||||||
taskOrderByProject: Record<string, string[]>;
|
|
||||||
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
|
||||||
onReloadOrganization: () => void;
|
onReloadOrganization: () => void;
|
||||||
onReloadPullRequests: () => void;
|
onReloadPullRequests: () => void;
|
||||||
onReloadRepository: (repoId: string) => void;
|
onReloadRepository: (repoId: string) => void;
|
||||||
|
|
@ -103,66 +98,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
||||||
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||||
const headerMenuRef = useRef<HTMLDivElement>(null);
|
const headerMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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<DragState>(null);
|
|
||||||
const dragRef = useRef<DragState>(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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!headerMenuOpen) {
|
if (!headerMenuOpen) {
|
||||||
|
|
@ -180,6 +116,26 @@ export const Sidebar = memo(function Sidebar({
|
||||||
|
|
||||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
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<FlatItem[]>(
|
||||||
|
() =>
|
||||||
|
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 (
|
return (
|
||||||
<SPanel>
|
<SPanel>
|
||||||
|
|
@ -463,342 +419,270 @@ export const Sidebar = memo(function Sidebar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PanelHeaderBar>
|
</PanelHeaderBar>
|
||||||
<ScrollBody>
|
<ScrollBody ref={scrollRef}>
|
||||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
<div className={css({ padding: "8px" })}>
|
||||||
{projects.map((project, projectIndex) => {
|
<div
|
||||||
const isCollapsed = collapsedProjects[project.id] === true;
|
className={css({ position: "relative", width: "100%" })}
|
||||||
const isProjectDropTarget = drag?.type === "project" && drag.overIdx === projectIndex && drag.fromIdx !== projectIndex;
|
style={{
|
||||||
const isBeingDragged = drag?.type === "project" && drag.fromIdx === projectIndex && didDragRef.current;
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
const orderedTaskIds = taskOrderByProject[project.id];
|
}}
|
||||||
const orderedTasks = orderedTaskIds
|
>
|
||||||
? (() => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const byId = new Map(project.tasks.map((t) => [t.id, t]));
|
const item = flatItems[virtualItem.index];
|
||||||
const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof project.tasks;
|
if (!item) {
|
||||||
for (const t of project.tasks) {
|
return null;
|
||||||
if (!orderedTaskIds.includes(t.id)) sorted.push(t);
|
}
|
||||||
}
|
|
||||||
return sorted;
|
|
||||||
})()
|
|
||||||
: project.tasks;
|
|
||||||
|
|
||||||
return (
|
if (item.type === "project-header") {
|
||||||
<div
|
const project = item.project;
|
||||||
key={project.id}
|
const isCollapsed = collapsedProjects[project.id] === true;
|
||||||
data-project-idx={projectIndex}
|
|
||||||
className={css({
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "4px",
|
|
||||||
position: "relative",
|
|
||||||
opacity: isBeingDragged ? 0.4 : 1,
|
|
||||||
transition: "opacity 150ms ease",
|
|
||||||
"::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
top: "-2px",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: "2px",
|
|
||||||
backgroundColor: isProjectDropTarget ? t.textPrimary : "transparent",
|
|
||||||
transition: "background-color 100ms ease",
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onMouseEnter={() => 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",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", overflow: "hidden" })}>
|
|
||||||
<div className={css({ position: "relative", width: "14px", height: "14px", flexShrink: 0 })}>
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: "3px",
|
|
||||||
fontSize: "9px",
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: t.textOnAccent,
|
|
||||||
backgroundColor: projectIconColor(project.label),
|
|
||||||
})}
|
|
||||||
data-project-icon
|
|
||||||
>
|
|
||||||
{projectInitial(project.label)}
|
|
||||||
</span>
|
|
||||||
<span className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })} data-chevron>
|
|
||||||
{isCollapsed ? <ChevronDown size={12} color={t.textTertiary} /> : <ChevronUp size={12} color={t.textTertiary} />}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<LabelSmall
|
|
||||||
color={t.textSecondary}
|
|
||||||
$style={{
|
|
||||||
fontSize: "11px",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stripCommonOrgPrefix(project.label, projects)}
|
|
||||||
</LabelSmall>
|
|
||||||
</div>
|
|
||||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
|
||||||
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setHoveredProjectId(null);
|
|
||||||
onSelectNewTaskRepo(project.id);
|
|
||||||
onCreate(project.id);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
className={css({
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "26px",
|
|
||||||
height: "26px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "none",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
padding: 0,
|
|
||||||
margin: 0,
|
|
||||||
cursor: "pointer",
|
|
||||||
color: t.textTertiary,
|
|
||||||
opacity: hoveredProjectId === project.id ? 1 : 0,
|
|
||||||
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
|
||||||
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
|
||||||
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
|
||||||
})}
|
|
||||||
title={`New task in ${project.label}`}
|
|
||||||
>
|
|
||||||
<Plus size={12} color={t.textTertiary} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed &&
|
return (
|
||||||
orderedTasks.map((task, taskIndex) => {
|
<div
|
||||||
const isActive = task.id === activeId;
|
key={item.key}
|
||||||
const isPullRequestItem = isPullRequestSidebarItem(task);
|
ref={(node) => {
|
||||||
const isDim = task.status === "archived";
|
if (node) {
|
||||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
virtualizer.measureElement(node);
|
||||||
const isProvisioning =
|
}
|
||||||
!isPullRequestItem &&
|
}}
|
||||||
(String(task.status).startsWith("init_") ||
|
style={{
|
||||||
task.status === "new" ||
|
left: 0,
|
||||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
position: "absolute",
|
||||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
top: 0,
|
||||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
width: "100%",
|
||||||
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;
|
<div className={css({ paddingBottom: "4px" })}>
|
||||||
const isTaskBeingDragged = drag?.type === "task" && drag.projectId === project.id && drag.fromIdx === taskIndex && didDragRef.current;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
onMouseEnter={() => setHoveredProjectId(project.id)}
|
||||||
data-task-idx={taskIndex}
|
onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))}
|
||||||
data-task-project-id={project.id}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!didDragRef.current) {
|
setCollapsedProjects((current) => ({
|
||||||
onSelect(task.id);
|
...current,
|
||||||
}
|
[project.id]: !current[project.id],
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
onContextMenu={(event) => {
|
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, [
|
contextMenu.open(event, [
|
||||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
|
||||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
{ label: "New task", onClick: () => onCreate(project.id) },
|
||||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
])
|
||||||
]);
|
}
|
||||||
}}
|
data-project-header
|
||||||
className={css({
|
className={css({
|
||||||
padding: "8px 12px",
|
display: "flex",
|
||||||
borderRadius: "8px",
|
alignItems: "center",
|
||||||
position: "relative",
|
justifyContent: "space-between",
|
||||||
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
padding: "10px 8px 4px",
|
||||||
opacity: isTaskBeingDragged ? 0.4 : 1,
|
gap: "8px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "all 150ms ease",
|
userSelect: "none",
|
||||||
"::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,
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
<div className={css({ display: "flex", alignItems: "center", gap: "4px", overflow: "hidden" })}>
|
||||||
<div
|
<div className={css({ position: "relative", width: "14px", height: "14px", flexShrink: 0 })}>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "3px",
|
||||||
|
fontSize: "9px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: t.textOnAccent,
|
||||||
|
backgroundColor: projectIconColor(project.label),
|
||||||
|
})}
|
||||||
|
data-project-icon
|
||||||
|
>
|
||||||
|
{projectInitial(project.label)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })}
|
||||||
|
data-chevron
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronDown size={12} color={t.textTertiary} /> : <ChevronUp size={12} color={t.textTertiary} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<LabelSmall
|
||||||
|
color={t.textSecondary}
|
||||||
|
$style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stripCommonOrgPrefix(project.label, projects)}
|
||||||
|
</LabelSmall>
|
||||||
|
</div>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||||
|
{isCollapsed ? <LabelXSmall color={t.textTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setHoveredProjectId(null);
|
||||||
|
onSelectNewTaskRepo(project.id);
|
||||||
|
onCreate(project.id);
|
||||||
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
width: "14px",
|
display: "inline-flex",
|
||||||
minWidth: "14px",
|
|
||||||
height: "14px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
width: "26px",
|
||||||
|
height: "26px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: t.textTertiary,
|
||||||
|
opacity: hoveredProjectId === project.id ? 1 : 0,
|
||||||
|
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
||||||
|
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
||||||
|
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
||||||
})}
|
})}
|
||||||
|
title={`New task in ${project.label}`}
|
||||||
>
|
>
|
||||||
{isPullRequestItem ? (
|
<Plus size={12} color={t.textTertiary} />
|
||||||
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
</button>
|
||||||
) : (
|
|
||||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
|
||||||
<LabelSmall
|
|
||||||
$style={{
|
|
||||||
fontWeight: hasUnread ? 600 : 400,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
minWidth: 0,
|
|
||||||
flexShrink: 1,
|
|
||||||
}}
|
|
||||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</LabelSmall>
|
|
||||||
{isPullRequestItem && task.statusMessage ? (
|
|
||||||
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
||||||
{task.statusMessage}
|
|
||||||
</LabelXSmall>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{task.pullRequest != null ? (
|
|
||||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
|
||||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
|
||||||
#{task.pullRequest.number}
|
|
||||||
</LabelXSmall>
|
|
||||||
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<GitPullRequestDraft size={11} color={t.textTertiary} />
|
|
||||||
)}
|
|
||||||
{hasDiffs ? (
|
|
||||||
<div className={css({ display: "flex", gap: "4px", flexShrink: 0, marginLeft: "auto" })}>
|
|
||||||
<span className={css({ fontSize: "11px", color: t.statusSuccess })}>+{totalAdded}</span>
|
|
||||||
<span className={css({ fontSize: "11px", color: t.statusError })}>-{totalRemoved}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<LabelXSmall color={t.textTertiary} $style={{ flexShrink: 0, marginLeft: hasDiffs ? undefined : "auto" }}>
|
|
||||||
{formatRelativeAge(task.updatedAtMs)}
|
|
||||||
</LabelXSmall>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
{/* Bottom drop zone for dragging to end of task list */}
|
);
|
||||||
{!isCollapsed && (
|
}
|
||||||
<div
|
|
||||||
data-task-idx={orderedTasks.length}
|
const { project, task } = item;
|
||||||
data-task-project-id={project.id}
|
const isActive = task.id === activeId;
|
||||||
className={css({
|
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||||
minHeight: "4px",
|
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||||
position: "relative",
|
const isProvisioning =
|
||||||
"::before": {
|
!isPullRequestItem &&
|
||||||
content: '""',
|
(String(task.status).startsWith("init_") ||
|
||||||
position: "absolute",
|
task.status === "new" ||
|
||||||
top: 0,
|
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
||||||
left: 0,
|
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||||
right: 0,
|
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||||
height: "2px",
|
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||||
backgroundColor:
|
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
|
||||||
drag?.type === "task" && drag.projectId === project.id && drag.overIdx === orderedTasks.length && drag.fromIdx !== orderedTasks.length
|
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
|
||||||
? t.textPrimary
|
|
||||||
: "transparent",
|
return (
|
||||||
transition: "background-color 100ms ease",
|
<div
|
||||||
},
|
key={item.key}
|
||||||
})}
|
ref={(node) => {
|
||||||
/>
|
if (node) {
|
||||||
)}
|
virtualizer.measureElement(node);
|
||||||
</div>
|
}
|
||||||
);
|
}}
|
||||||
})}
|
style={{
|
||||||
{/* Bottom drop zone for dragging project to end of list */}
|
left: 0,
|
||||||
<div
|
position: "absolute",
|
||||||
data-project-idx={projects.length}
|
top: 0,
|
||||||
className={css({
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
minHeight: "4px",
|
width: "100%",
|
||||||
position: "relative",
|
}}
|
||||||
"::before": {
|
>
|
||||||
content: '""',
|
<div className={css({ paddingBottom: "4px" })}>
|
||||||
position: "absolute",
|
<div
|
||||||
top: 0,
|
onClick={() => onSelect(task.id)}
|
||||||
left: 0,
|
onContextMenu={(event) => {
|
||||||
right: 0,
|
if (isPullRequestItem && task.pullRequest) {
|
||||||
height: "2px",
|
contextMenu.open(event, [
|
||||||
backgroundColor:
|
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||||
drag?.type === "project" && drag.overIdx === projects.length && drag.fromIdx !== projects.length ? t.textPrimary : "transparent",
|
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||||
transition: "background-color 100ms ease",
|
]);
|
||||||
},
|
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,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: "14px",
|
||||||
|
minWidth: "14px",
|
||||||
|
height: "14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isPullRequestItem ? (
|
||||||
|
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||||
|
) : (
|
||||||
|
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||||
|
<LabelSmall
|
||||||
|
$style={{
|
||||||
|
fontWeight: hasUnread ? 600 : 400,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</LabelSmall>
|
||||||
|
{isPullRequestItem && task.statusMessage ? (
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{task.statusMessage}
|
||||||
|
</LabelXSmall>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{task.pullRequest != null ? (
|
||||||
|
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||||
|
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||||
|
#{task.pullRequest.number}
|
||||||
|
</LabelXSmall>
|
||||||
|
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<GitPullRequestDraft size={11} color={t.textTertiary} />
|
||||||
|
)}
|
||||||
|
{hasDiffs ? (
|
||||||
|
<div className={css({ display: "flex", gap: "4px", flexShrink: 0, marginLeft: "auto" })}>
|
||||||
|
<span className={css({ fontSize: "11px", color: t.statusSuccess })}>+{totalAdded}</span>
|
||||||
|
<span className={css({ fontSize: "11px", color: t.statusError })}>-{totalRemoved}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ flexShrink: 0, marginLeft: hasDiffs ? undefined : "auto" }}>
|
||||||
|
{formatRelativeAge(task.updatedAtMs)}
|
||||||
|
</LabelXSmall>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollBody>
|
</ScrollBody>
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ export default function App() {
|
||||||
const [highlightedEventId, setHighlightedEventId] = useState<string | null>(null);
|
const [highlightedEventId, setHighlightedEventId] = useState<string | null>(null);
|
||||||
const [debugPanelCollapsed, setDebugPanelCollapsed] = useState(false);
|
const [debugPanelCollapsed, setDebugPanelCollapsed] = useState(false);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const transcriptScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const clientRef = useRef<SandboxAgent | null>(null);
|
const clientRef = useRef<SandboxAgent | null>(null);
|
||||||
const activeSessionRef = useRef<Session | null>(null);
|
const activeSessionRef = useRef<Session | null>(null);
|
||||||
|
|
@ -1434,10 +1434,6 @@ export default function App() {
|
||||||
});
|
});
|
||||||
}, [connected, sessionId, sessions, getClient, subscribeToSession]);
|
}, [connected, sessionId, sessions, getClient, subscribeToSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [transcriptEntries]);
|
|
||||||
|
|
||||||
const currentAgent = agents.find((agent) => agent.id === agentId);
|
const currentAgent = agents.find((agent) => agent.id === agentId);
|
||||||
const agentLabel = agentDisplayNames[agentId] ?? agentId;
|
const agentLabel = agentDisplayNames[agentId] ?? agentId;
|
||||||
const selectedSession = sessions.find((s) => s.sessionId === sessionId);
|
const selectedSession = sessions.find((s) => s.sessionId === sessionId);
|
||||||
|
|
@ -1743,7 +1739,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
agentsLoading={agentsLoading}
|
agentsLoading={agentsLoading}
|
||||||
agentsError={agentsError}
|
agentsError={agentsError}
|
||||||
messagesEndRef={messagesEndRef}
|
scrollRef={transcriptScrollRef}
|
||||||
agentLabel={agentLabel}
|
agentLabel={agentLabel}
|
||||||
modelLabel={modelPillLabel}
|
modelLabel={modelPillLabel}
|
||||||
currentAgentVersion={currentAgent?.version ?? null}
|
currentAgentVersion={currentAgent?.version ?? null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { TranscriptEntry } from "@sandbox-agent/react";
|
import type { TranscriptEntry } from "@sandbox-agent/react";
|
||||||
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-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 type { AgentInfo } from "sandbox-agent";
|
||||||
import { formatShortId } from "../../utils/format";
|
import { formatShortId } from "../../utils/format";
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ const ChatPanel = ({
|
||||||
agents,
|
agents,
|
||||||
agentsLoading,
|
agentsLoading,
|
||||||
agentsError,
|
agentsError,
|
||||||
messagesEndRef,
|
scrollRef,
|
||||||
agentLabel,
|
agentLabel,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
currentAgentVersion,
|
currentAgentVersion,
|
||||||
|
|
@ -71,7 +71,7 @@ const ChatPanel = ({
|
||||||
agents: AgentInfo[];
|
agents: AgentInfo[];
|
||||||
agentsLoading: boolean;
|
agentsLoading: boolean;
|
||||||
agentsError: string | null;
|
agentsError: string | null;
|
||||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
scrollRef: RefObject<HTMLDivElement>;
|
||||||
agentLabel: string;
|
agentLabel: string;
|
||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
currentAgentVersion?: string | null;
|
currentAgentVersion?: string | null;
|
||||||
|
|
@ -233,7 +233,7 @@ const ChatPanel = ({
|
||||||
entries={transcriptEntries}
|
entries={transcriptEntries}
|
||||||
sessionError={sessionError}
|
sessionError={sessionError}
|
||||||
eventError={null}
|
eventError={null}
|
||||||
messagesEndRef={messagesEndRef}
|
scrollRef={scrollRef}
|
||||||
onEventClick={onEventClick}
|
onEventClick={onEventClick}
|
||||||
isThinking={isThinking}
|
isThinking={isThinking}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
type TranscriptEntry,
|
type TranscriptEntry,
|
||||||
} from "@sandbox-agent/react";
|
} from "@sandbox-agent/react";
|
||||||
import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } from "lucide-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";
|
import MarkdownText from "./MarkdownText";
|
||||||
|
|
||||||
const agentLogos: Record<string, string> = {
|
const agentLogos: Record<string, string> = {
|
||||||
|
|
@ -84,7 +84,7 @@ export interface InspectorConversationProps {
|
||||||
entries: TranscriptEntry[];
|
entries: TranscriptEntry[];
|
||||||
sessionError: string | null;
|
sessionError: string | null;
|
||||||
eventError?: string | null;
|
eventError?: string | null;
|
||||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
scrollRef: RefObject<HTMLDivElement>;
|
||||||
onEventClick?: (eventId: string) => void;
|
onEventClick?: (eventId: string) => void;
|
||||||
isThinking?: boolean;
|
isThinking?: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
@ -102,7 +102,7 @@ const InspectorConversation = ({
|
||||||
entries,
|
entries,
|
||||||
sessionError,
|
sessionError,
|
||||||
eventError,
|
eventError,
|
||||||
messagesEndRef,
|
scrollRef,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
isThinking,
|
isThinking,
|
||||||
agentId,
|
agentId,
|
||||||
|
|
@ -119,12 +119,13 @@ const InspectorConversation = ({
|
||||||
<AgentConversation
|
<AgentConversation
|
||||||
entries={entries}
|
entries={entries}
|
||||||
classNames={conversationClassNames}
|
classNames={conversationClassNames}
|
||||||
|
scrollRef={scrollRef}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
transcriptClassNames={transcriptClassNames}
|
transcriptClassNames={transcriptClassNames}
|
||||||
transcriptProps={{
|
transcriptProps={{
|
||||||
endRef: messagesEndRef,
|
|
||||||
sessionError,
|
sessionError,
|
||||||
eventError,
|
eventError,
|
||||||
|
virtualize: true,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
isThinking,
|
isThinking,
|
||||||
agentId,
|
agentId,
|
||||||
|
|
|
||||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
|
|
@ -35,7 +35,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@boxlite-ai/boxlite':
|
'@boxlite-ai/boxlite':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 0.4.2
|
version: 0.4.3
|
||||||
'@sandbox-agent/example-shared':
|
'@sandbox-agent/example-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
|
|
@ -550,6 +550,9 @@ importers:
|
||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.132.23
|
specifier: ^1.132.23
|
||||||
version: 1.166.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.166.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.22
|
||||||
|
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
baseui:
|
baseui:
|
||||||
specifier: ^16.1.1
|
specifier: ^16.1.1
|
||||||
version: 16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
|
version: 16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
|
||||||
|
|
@ -961,6 +964,9 @@ importers:
|
||||||
|
|
||||||
sdks/react:
|
sdks/react:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.22
|
||||||
|
version: 3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)
|
||||||
ghostty-web:
|
ghostty-web:
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
|
|
@ -1512,20 +1518,20 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@boxlite-ai/boxlite-darwin-arm64@0.4.2':
|
'@boxlite-ai/boxlite-darwin-arm64@0.4.3':
|
||||||
resolution: {integrity: sha512-FwTfA8AyDXwoDb7nE7vGo04uBt2VDAiEl5leNNzroGSUKoTuCxNy8JfEbwwHb54UrIp/q7GNq7hG0JtmyxuubQ==}
|
resolution: {integrity: sha512-1t9bNSPz8ql55lneojWe66vi4VbAJ6lYsVu8A1Hd3T3uug0HnXpQSvuEhGWrp2Ibavtxtr8MZ/KaW3EMPbyjXg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.2':
|
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.3':
|
||||||
resolution: {integrity: sha512-UIRiTKl1L0cx2igDiikEiBfpNbTZ0W3lft5ow7I2mkDnjtBVIQYSm+PmVXBupTYivAuPh38g9WhqJH44C1RJdQ==}
|
resolution: {integrity: sha512-e5Ukl2pyqFe046cA+VcDUL9iso1OseHS13BEDnr/ADKsG+P//bYZHnE0JZPJL1ai4+fHg6d6BOe113rOxba1eQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@boxlite-ai/boxlite@0.4.2':
|
'@boxlite-ai/boxlite@0.4.3':
|
||||||
resolution: {integrity: sha512-LVxG0feP1sBGbYz/VOm11VsU8PyUv7rvXOQJqKrfBgI9oRVyqycpY39PCJ1oC+FFho7w7d61q8VCVDlDdj8i6Q==}
|
resolution: {integrity: sha512-bCYSrJH8mAlz+JoyVkCUSfYuCp2IwqaLrvOu4m1vstq6LNwkLcpmJzs9gLXrHnYb+YitYko3pQiK8uTieG4BJw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
playwright-core: '>=1.58.0'
|
playwright-core: '>=1.58.0'
|
||||||
|
|
@ -3642,6 +3648,12 @@ packages:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
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':
|
'@tanstack/router-core@1.166.4':
|
||||||
resolution: {integrity: sha512-T/RrsAvznqNJqfT7nrj3S+/RiQmW4U/i4Vii8KdOQdhahPzAQnmRzZB+SUwR4quqRYql5o2zmCA6Brg1961hHg==}
|
resolution: {integrity: sha512-T/RrsAvznqNJqfT7nrj3S+/RiQmW4U/i4Vii8KdOQdhahPzAQnmRzZB+SUwR4quqRYql5o2zmCA6Brg1961hHg==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
|
|
@ -3649,6 +3661,9 @@ packages:
|
||||||
'@tanstack/store@0.9.2':
|
'@tanstack/store@0.9.2':
|
||||||
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
|
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.22':
|
||||||
|
resolution: {integrity: sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
|
|
@ -8419,16 +8434,16 @@ snapshots:
|
||||||
'@biomejs/cli-win32-x64@2.4.6':
|
'@biomejs/cli-win32-x64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@boxlite-ai/boxlite-darwin-arm64@0.4.2':
|
'@boxlite-ai/boxlite-darwin-arm64@0.4.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.2':
|
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@boxlite-ai/boxlite@0.4.2':
|
'@boxlite-ai/boxlite@0.4.3':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@boxlite-ai/boxlite-darwin-arm64': 0.4.2
|
'@boxlite-ai/boxlite-darwin-arm64': 0.4.3
|
||||||
'@boxlite-ai/boxlite-linux-x64-gnu': 0.4.2
|
'@boxlite-ai/boxlite-linux-x64-gnu': 0.4.3
|
||||||
|
|
||||||
'@bufbuild/protobuf@2.11.0': {}
|
'@bufbuild/protobuf@2.11.0': {}
|
||||||
|
|
||||||
|
|
@ -10246,6 +10261,18 @@ snapshots:
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
use-sync-external-store: 1.6.0(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':
|
'@tanstack/router-core@1.166.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/history': 1.161.4
|
'@tanstack/history': 1.161.4
|
||||||
|
|
@ -10258,6 +10285,8 @@ snapshots:
|
||||||
|
|
||||||
'@tanstack/store@0.9.2': {}
|
'@tanstack/store@0.9.2': {}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.22': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.28.6
|
'@babel/parser': 7.28.6
|
||||||
|
|
@ -13238,6 +13267,11 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
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):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"sandbox-agent": "^0.2.2"
|
"sandbox-agent": "^0.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"ghostty-web": "^0.4.0"
|
"ghostty-web": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 { AgentTranscript, type AgentTranscriptClassNames, type AgentTranscriptProps, type TranscriptEntry } from "./AgentTranscript.tsx";
|
||||||
import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx";
|
import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx";
|
||||||
|
|
||||||
|
|
@ -18,9 +18,10 @@ export interface AgentConversationProps {
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
transcriptClassName?: string;
|
transcriptClassName?: string;
|
||||||
transcriptClassNames?: Partial<AgentTranscriptClassNames>;
|
transcriptClassNames?: Partial<AgentTranscriptClassNames>;
|
||||||
|
scrollRef?: RefObject<HTMLDivElement>;
|
||||||
composerClassName?: string;
|
composerClassName?: string;
|
||||||
composerClassNames?: Partial<ChatComposerClassNames>;
|
composerClassNames?: Partial<ChatComposerClassNames>;
|
||||||
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames">;
|
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames" | "scrollRef">;
|
||||||
composerProps?: Omit<ChatComposerProps, "className" | "classNames">;
|
composerProps?: Omit<ChatComposerProps, "className" | "classNames">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ export const AgentConversation = ({
|
||||||
emptyState,
|
emptyState,
|
||||||
transcriptClassName,
|
transcriptClassName,
|
||||||
transcriptClassNames,
|
transcriptClassNames,
|
||||||
|
scrollRef,
|
||||||
composerClassName,
|
composerClassName,
|
||||||
composerClassNames,
|
composerClassNames,
|
||||||
transcriptProps,
|
transcriptProps,
|
||||||
|
|
@ -58,12 +60,18 @@ export const AgentConversation = ({
|
||||||
return (
|
return (
|
||||||
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
|
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
|
||||||
{hasTranscriptContent ? (
|
{hasTranscriptContent ? (
|
||||||
<AgentTranscript
|
scrollRef ? (
|
||||||
entries={entries}
|
<div className={cx(resolvedClassNames.transcript, transcriptClassName)} data-slot="transcript" ref={scrollRef}>
|
||||||
className={cx(resolvedClassNames.transcript, transcriptClassName)}
|
<AgentTranscript entries={entries} classNames={transcriptClassNames} {...transcriptProps} />
|
||||||
classNames={transcriptClassNames}
|
</div>
|
||||||
{...transcriptProps}
|
) : (
|
||||||
/>
|
<AgentTranscript
|
||||||
|
entries={entries}
|
||||||
|
className={cx(resolvedClassNames.transcript, transcriptClassName)}
|
||||||
|
classNames={transcriptClassNames}
|
||||||
|
{...transcriptProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : emptyState ? (
|
) : emptyState ? (
|
||||||
<div className={resolvedClassNames.emptyState} data-slot="empty-state">
|
<div className={resolvedClassNames.emptyState} data-slot="empty-state">
|
||||||
{emptyState}
|
{emptyState}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode, RefObject } from "react";
|
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";
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
|
|
@ -98,10 +99,14 @@ export interface AgentTranscriptProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
classNames?: Partial<AgentTranscriptClassNames>;
|
classNames?: Partial<AgentTranscriptClassNames>;
|
||||||
endRef?: RefObject<HTMLDivElement>;
|
endRef?: RefObject<HTMLDivElement>;
|
||||||
|
scrollRef?: RefObject<HTMLDivElement>;
|
||||||
|
scrollToEntryId?: string | null;
|
||||||
sessionError?: string | null;
|
sessionError?: string | null;
|
||||||
eventError?: string | null;
|
eventError?: string | null;
|
||||||
isThinking?: boolean;
|
isThinking?: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
virtualize?: boolean;
|
||||||
|
onAtBottomChange?: (atBottom: boolean) => void;
|
||||||
onEventClick?: (eventId: string) => void;
|
onEventClick?: (eventId: string) => void;
|
||||||
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
||||||
isDividerEntry?: (entry: TranscriptEntry) => boolean;
|
isDividerEntry?: (entry: TranscriptEntry) => boolean;
|
||||||
|
|
@ -124,6 +129,8 @@ type GroupedEntries =
|
||||||
| { type: "divider"; entries: TranscriptEntry[] }
|
| { type: "divider"; entries: TranscriptEntry[] }
|
||||||
| { type: "permission"; entries: TranscriptEntry[] };
|
| { type: "permission"; entries: TranscriptEntry[] };
|
||||||
|
|
||||||
|
const VIRTUAL_GROUP_GAP_PX = 12;
|
||||||
|
|
||||||
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
||||||
root: "sa-agent-transcript",
|
root: "sa-agent-transcript",
|
||||||
divider: "sa-agent-transcript-divider",
|
divider: "sa-agent-transcript-divider",
|
||||||
|
|
@ -324,9 +331,21 @@ const buildGroupedEntries = (entries: TranscriptEntry[], isDividerEntry: (entry:
|
||||||
return groupedEntries;
|
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 = ({
|
const ToolItem = ({
|
||||||
entry,
|
entry,
|
||||||
isLast,
|
isLast,
|
||||||
|
expanded,
|
||||||
|
onExpandedChange,
|
||||||
classNames,
|
classNames,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
canOpenEvent,
|
canOpenEvent,
|
||||||
|
|
@ -337,6 +356,8 @@ const ToolItem = ({
|
||||||
}: {
|
}: {
|
||||||
entry: TranscriptEntry;
|
entry: TranscriptEntry;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
classNames: AgentTranscriptClassNames;
|
classNames: AgentTranscriptClassNames;
|
||||||
onEventClick?: (eventId: string) => void;
|
onEventClick?: (eventId: string) => void;
|
||||||
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
||||||
|
|
@ -345,7 +366,6 @@ const ToolItem = ({
|
||||||
renderChevron: (expanded: boolean) => ReactNode;
|
renderChevron: (expanded: boolean) => ReactNode;
|
||||||
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const isTool = entry.kind === "tool";
|
const isTool = entry.kind === "tool";
|
||||||
const isReasoning = entry.kind === "reasoning";
|
const isReasoning = entry.kind === "reasoning";
|
||||||
const isMeta = entry.kind === "meta";
|
const isMeta = entry.kind === "meta";
|
||||||
|
|
@ -382,7 +402,7 @@ const ToolItem = ({
|
||||||
disabled={!hasContent}
|
disabled={!hasContent}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
setExpanded((value) => !value);
|
onExpandedChange(!expanded);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -469,6 +489,10 @@ const ToolItem = ({
|
||||||
|
|
||||||
const ToolGroup = ({
|
const ToolGroup = ({
|
||||||
entries,
|
entries,
|
||||||
|
expanded,
|
||||||
|
onExpandedChange,
|
||||||
|
expandedItemIds,
|
||||||
|
onToolItemExpandedChange,
|
||||||
classNames,
|
classNames,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
canOpenEvent,
|
canOpenEvent,
|
||||||
|
|
@ -480,6 +504,10 @@ const ToolGroup = ({
|
||||||
renderEventLinkContent,
|
renderEventLinkContent,
|
||||||
}: {
|
}: {
|
||||||
entries: TranscriptEntry[];
|
entries: TranscriptEntry[];
|
||||||
|
expanded: boolean;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
expandedItemIds: Record<string, boolean>;
|
||||||
|
onToolItemExpandedChange: (entryId: string, expanded: boolean) => void;
|
||||||
classNames: AgentTranscriptClassNames;
|
classNames: AgentTranscriptClassNames;
|
||||||
onEventClick?: (eventId: string) => void;
|
onEventClick?: (eventId: string) => void;
|
||||||
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
||||||
|
|
@ -490,7 +518,6 @@ const ToolGroup = ({
|
||||||
renderChevron: (expanded: boolean) => ReactNode;
|
renderChevron: (expanded: boolean) => ReactNode;
|
||||||
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed");
|
const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed");
|
||||||
|
|
||||||
if (entries.length === 1) {
|
if (entries.length === 1) {
|
||||||
|
|
@ -499,6 +526,8 @@ const ToolGroup = ({
|
||||||
<ToolItem
|
<ToolItem
|
||||||
entry={entries[0]}
|
entry={entries[0]}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
|
expanded={Boolean(expandedItemIds[entries[0]!.id])}
|
||||||
|
onExpandedChange={(nextExpanded) => onToolItemExpandedChange(entries[0]!.id, nextExpanded)}
|
||||||
classNames={classNames}
|
classNames={classNames}
|
||||||
onEventClick={onEventClick}
|
onEventClick={onEventClick}
|
||||||
canOpenEvent={canOpenEvent}
|
canOpenEvent={canOpenEvent}
|
||||||
|
|
@ -518,7 +547,7 @@ const ToolGroup = ({
|
||||||
className={cx(classNames.toolGroupHeader, expanded && "expanded")}
|
className={cx(classNames.toolGroupHeader, expanded && "expanded")}
|
||||||
data-slot="tool-group-header"
|
data-slot="tool-group-header"
|
||||||
data-expanded={expanded ? "true" : undefined}
|
data-expanded={expanded ? "true" : undefined}
|
||||||
onClick={() => setExpanded((value) => !value)}
|
onClick={() => onExpandedChange(!expanded)}
|
||||||
>
|
>
|
||||||
<span className={classNames.toolGroupIcon} data-slot="tool-group-icon">
|
<span className={classNames.toolGroupIcon} data-slot="tool-group-icon">
|
||||||
{renderToolGroupIcon(entries, expanded)}
|
{renderToolGroupIcon(entries, expanded)}
|
||||||
|
|
@ -537,6 +566,8 @@ const ToolGroup = ({
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
isLast={index === entries.length - 1}
|
isLast={index === entries.length - 1}
|
||||||
|
expanded={Boolean(expandedItemIds[entry.id])}
|
||||||
|
onExpandedChange={(nextExpanded) => onToolItemExpandedChange(entry.id, nextExpanded)}
|
||||||
classNames={classNames}
|
classNames={classNames}
|
||||||
onEventClick={onEventClick}
|
onEventClick={onEventClick}
|
||||||
canOpenEvent={canOpenEvent}
|
canOpenEvent={canOpenEvent}
|
||||||
|
|
@ -636,10 +667,14 @@ export const AgentTranscript = ({
|
||||||
className,
|
className,
|
||||||
classNames: classNameOverrides,
|
classNames: classNameOverrides,
|
||||||
endRef,
|
endRef,
|
||||||
|
scrollRef,
|
||||||
|
scrollToEntryId,
|
||||||
sessionError,
|
sessionError,
|
||||||
eventError,
|
eventError,
|
||||||
isThinking,
|
isThinking,
|
||||||
agentId,
|
agentId,
|
||||||
|
virtualize = false,
|
||||||
|
onAtBottomChange,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
onPermissionReply,
|
onPermissionReply,
|
||||||
isDividerEntry = defaultIsDividerEntry,
|
isDividerEntry = defaultIsDividerEntry,
|
||||||
|
|
@ -657,83 +692,199 @@ export const AgentTranscript = ({
|
||||||
}: AgentTranscriptProps) => {
|
}: AgentTranscriptProps) => {
|
||||||
const resolvedClassNames = useMemo(() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides), [classNameOverrides]);
|
const resolvedClassNames = useMemo(() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides), [classNameOverrides]);
|
||||||
const groupedEntries = useMemo(() => buildGroupedEntries(entries, isDividerEntry), [entries, isDividerEntry]);
|
const groupedEntries = useMemo(() => buildGroupedEntries(entries, isDividerEntry), [entries, isDividerEntry]);
|
||||||
|
const [expandedToolGroups, setExpandedToolGroups] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedToolItems, setExpandedToolItems] = useState<Record<string, boolean>>({});
|
||||||
|
const lastScrollTargetRef = useRef<string | null>(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 (
|
||||||
|
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
|
||||||
|
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||||
|
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.type === "tool-group") {
|
||||||
|
const groupKey = getGroupedEntryKey(group, index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolGroup
|
||||||
|
key={groupKey}
|
||||||
|
entries={group.entries}
|
||||||
|
expanded={Boolean(expandedToolGroups[groupKey])}
|
||||||
|
onExpandedChange={(expanded) => 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 (
|
||||||
|
<PermissionPrompt
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
classNames={resolvedClassNames}
|
||||||
|
onPermissionReply={onPermissionReply}
|
||||||
|
renderPermissionIcon={renderPermissionIcon}
|
||||||
|
renderPermissionOptionContent={renderPermissionOptionContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = group.entries[0];
|
||||||
|
const messageVariant = getMessageVariant(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
|
||||||
|
data-slot="message"
|
||||||
|
data-kind={entry.kind}
|
||||||
|
data-role={entry.role}
|
||||||
|
data-variant={messageVariant}
|
||||||
|
data-severity={entry.meta?.severity}
|
||||||
|
>
|
||||||
|
<div className={resolvedClassNames.messageContent} data-slot="message-content">
|
||||||
|
{entry.text ? (
|
||||||
|
<div className={resolvedClassNames.messageText} data-slot="message-text">
|
||||||
|
{renderMessageText(entry)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
|
||||||
|
{renderInlinePendingIndicator()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
|
<div className={cx(resolvedClassNames.root, className)} data-slot="root" data-virtualized={isVirtualized ? "true" : undefined}>
|
||||||
{groupedEntries.map((group, index) => {
|
{isVirtualized ? (
|
||||||
if (group.type === "divider") {
|
<div
|
||||||
const entry = group.entries[0];
|
data-slot="virtual-list"
|
||||||
const title = entry.meta?.title ?? "Status";
|
style={{
|
||||||
return (
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
|
position: "relative",
|
||||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
width: "100%",
|
||||||
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
|
}}
|
||||||
{title}
|
>
|
||||||
</span>
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
const group = groupedEntries[virtualItem.index];
|
||||||
</div>
|
if (!group) {
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.type === "tool-group") {
|
return (
|
||||||
return (
|
<div
|
||||||
<ToolGroup
|
key={getGroupedEntryKey(group, virtualItem.index)}
|
||||||
key={`tool-group-${index}`}
|
data-index={virtualItem.index}
|
||||||
entries={group.entries}
|
ref={(node) => {
|
||||||
classNames={resolvedClassNames}
|
if (node) {
|
||||||
onEventClick={onEventClick}
|
virtualizer.measureElement(node);
|
||||||
canOpenEvent={canOpenEvent}
|
}
|
||||||
getToolGroupSummary={getToolGroupSummary}
|
}}
|
||||||
renderInlinePendingIndicator={renderInlinePendingIndicator}
|
style={{
|
||||||
renderToolItemIcon={renderToolItemIcon}
|
left: 0,
|
||||||
renderToolGroupIcon={renderToolGroupIcon}
|
position: "absolute",
|
||||||
renderChevron={renderChevron}
|
top: 0,
|
||||||
renderEventLinkContent={renderEventLinkContent}
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
/>
|
width: "100%",
|
||||||
);
|
}}
|
||||||
}
|
>
|
||||||
|
<div style={{ paddingBottom: virtualItem.index === groupedEntries.length - 1 ? 0 : `${VIRTUAL_GROUP_GAP_PX}px` }}>
|
||||||
if (group.type === "permission") {
|
{renderGroup(group, virtualItem.index)}
|
||||||
const entry = group.entries[0];
|
|
||||||
return (
|
|
||||||
<PermissionPrompt
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
classNames={resolvedClassNames}
|
|
||||||
onPermissionReply={onPermissionReply}
|
|
||||||
renderPermissionIcon={renderPermissionIcon}
|
|
||||||
renderPermissionOptionContent={renderPermissionOptionContent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = group.entries[0];
|
|
||||||
const messageVariant = getMessageVariant(entry);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
|
|
||||||
data-slot="message"
|
|
||||||
data-kind={entry.kind}
|
|
||||||
data-role={entry.role}
|
|
||||||
data-variant={messageVariant}
|
|
||||||
data-severity={entry.meta?.severity}
|
|
||||||
>
|
|
||||||
<div className={resolvedClassNames.messageContent} data-slot="message-content">
|
|
||||||
{entry.text ? (
|
|
||||||
<div className={resolvedClassNames.messageText} data-slot="message-text">
|
|
||||||
{renderMessageText(entry)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
|
);
|
||||||
{renderInlinePendingIndicator()}
|
})}
|
||||||
</span>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
groupedEntries.map((group, index) => renderGroup(group, index))
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
{sessionError ? (
|
{sessionError ? (
|
||||||
<div className={resolvedClassNames.error} data-slot="error" data-source="session">
|
<div className={resolvedClassNames.error} data-slot="error" data-source="session">
|
||||||
{sessionError}
|
{sessionError}
|
||||||
|
|
@ -753,7 +904,7 @@ export const AgentTranscript = ({
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
<div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" />
|
{!isVirtualized ? <div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export { AgentConversation } from "./AgentConversation.tsx";
|
||||||
export { AgentTranscript } from "./AgentTranscript.tsx";
|
export { AgentTranscript } from "./AgentTranscript.tsx";
|
||||||
export { ChatComposer } from "./ChatComposer.tsx";
|
export { ChatComposer } from "./ChatComposer.tsx";
|
||||||
export { ProcessTerminal } from "./ProcessTerminal.tsx";
|
export { ProcessTerminal } from "./ProcessTerminal.tsx";
|
||||||
|
export { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgentConversationClassNames,
|
AgentConversationClassNames,
|
||||||
|
|
|
||||||
58
sdks/react/src/useTranscriptVirtualizer.ts
Normal file
58
sdks/react/src/useTranscriptVirtualizer.ts
Normal file
|
|
@ -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<T>(items: T[], scrollElementRef?: RefObject<HTMLDivElement>, 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue