refactor(site/src/pages/AgentsPage): require always-passed timeline and tool props (#29891)

This commit is contained in:
Danielle Maywood
2026-10-06 14:37:54 +01:00
committed by GitHub
parent 3c5de0a175
commit 0e81c4b954
31 changed files with 307 additions and 279 deletions
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, within } from "storybook/test";
import { expect, fn, screen, within } from "storybook/test";
import { defaultUrlTransform } from "streamdown";
import { AssistantOutput } from "./AssistantOutput";
import {
@@ -27,6 +27,11 @@ const LiveAssistantOutput = ({
tools={streamTools}
isStreaming={liveStatus.phase === "streaming"}
liveStatus={liveStatus}
subagentTitles={new Map()}
subagentVariants={new Map()}
hasUserResponseAfterAskQuestion={false}
onImageClick={fn()}
onTextFileClick={fn()}
/>
);
@@ -7,8 +7,8 @@ import type { LiveStatusModel } from "./liveStatusModel";
import { BlockList, type BlockListProps } from "./MessageBlocks";
import { shouldShowGenericThinking } from "./streamingActivity";
const LiveActivitySlot: React.FC<{ interrupting?: boolean }> = ({
interrupting = false,
const LiveActivitySlot: React.FC<{ interrupting: boolean }> = ({
interrupting,
}) => (
<div
data-testid="live-activity-slot"
@@ -33,7 +33,7 @@ import type { RenderBlock } from "./types";
export type PreviewTextAttachment = {
content: string;
fileName?: string;
fileName: string;
mediaType?: string;
};
@@ -188,7 +188,7 @@ const DownloadOverlay: React.FC<{
};
const AttachmentPreviewFrame: React.FC<{
href: string | null;
href: string;
displayName: string;
downloadName: string;
mediaType: string;
@@ -197,14 +197,12 @@ const AttachmentPreviewFrame: React.FC<{
return (
<div className="group/attachment relative inline-flex flex-col items-start">
{children}
{href ? (
<DownloadOverlay
href={href}
displayName={displayName}
downloadName={downloadName}
mediaType={mediaType}
/>
) : null}
<DownloadOverlay
href={href}
displayName={displayName}
downloadName={downloadName}
mediaType={mediaType}
/>
</div>
);
};
@@ -276,23 +274,21 @@ const AttachmentFallbackTile: React.FC<{
const InlineTextAttachmentButton: React.FC<{
content: string;
fileName?: string;
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
isPlaceholder?: boolean;
fileName: string;
onPreview: (attachment: PreviewTextAttachment) => void | Promise<void>;
isPlaceholder: boolean;
icon?: React.ReactNode;
}> = ({ content, fileName, onPreview, isPlaceholder, icon }) => {
return (
<button
type="button"
aria-label={
fileName && fileName !== "Pasted text"
? `View ${fileName}`
: "View text attachment"
fileName !== "Pasted text" ? `View ${fileName}` : "View text attachment"
}
className="inline-flex h-16 max-w-sm items-center gap-2 rounded-md border-0 bg-surface-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-content-link"
onClick={(event) => {
event.stopPropagation();
void onPreview?.({ content, fileName });
void onPreview({ content, fileName });
}}
>
{icon ?? (
@@ -315,21 +311,12 @@ const InlineTextAttachmentButton: React.FC<{
const RemoteTextAttachmentButton: React.FC<{
fileId: string;
fileName?: string;
mediaType?: string;
fileName: string;
mediaType: string;
frameHref?: string | null;
downloadName: string;
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
showStatus?: boolean;
}> = ({
fileId,
fileName,
mediaType,
frameHref,
downloadName,
onPreview,
showStatus = false,
}) => {
onPreview: (attachment: PreviewTextAttachment) => void | Promise<void>;
}> = ({ fileId, fileName, mediaType, frameHref, downloadName, onPreview }) => {
const { hasExpired, markExpired } = useFileProbes();
const isKnownExpired = hasExpired(fileId);
const [content, setContent] = useState<string | null>(null);
@@ -360,10 +347,10 @@ const RemoteTextAttachmentButton: React.FC<{
const button = (
<InlineTextAttachmentButton
content={content ?? fileName ?? "Pasted text"}
content={content ?? fileName}
fileName={fileName}
icon={
showStatus && isLoading ? (
isLoading ? (
<Spinner
size="sm"
loading
@@ -377,7 +364,7 @@ const RemoteTextAttachmentButton: React.FC<{
return;
}
if (content !== null) {
void onPreview?.({ content, fileName, mediaType });
void onPreview({ content, fileName, mediaType });
return;
}
@@ -412,7 +399,7 @@ const RemoteTextAttachmentButton: React.FC<{
return;
}
setContent(result.content);
void onPreview?.({ content: result.content, fileName, mediaType });
void onPreview({ content: result.content, fileName, mediaType });
}}
/>
);
@@ -420,9 +407,9 @@ const RemoteTextAttachmentButton: React.FC<{
const framedButton = frameHref ? (
<AttachmentPreviewFrame
href={frameHref}
displayName={fileName ?? "Pasted text"}
displayName={fileName}
downloadName={downloadName}
mediaType={mediaType ?? ""}
mediaType={mediaType}
>
{button}
</AttachmentPreviewFrame>
@@ -433,7 +420,7 @@ const RemoteTextAttachmentButton: React.FC<{
return (
<div className="flex flex-col items-start gap-1">
{framedButton}
{showStatus && isLoading ? (
{isLoading ? (
<span
role="status"
aria-live="polite"
@@ -450,7 +437,7 @@ const RemoteImageBlock: React.FC<{
fileId?: string;
href: string;
displayName: string;
onImageClick?: (src: string) => void;
onImageClick: (src: string) => void;
}> = ({ fileId, href, displayName, onImageClick }) => {
const {
hasExpired,
@@ -493,7 +480,7 @@ const RemoteImageBlock: React.FC<{
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
onImageClick?.(href);
onImageClick(href);
}}
>
<ImageThumbnail
@@ -618,17 +605,10 @@ const FileCard: React.FC<{
export const AttachmentBlock: React.FC<{
block: FileAttachmentBlock;
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImageClick: (src: string) => void;
onTextFileClick: (attachment: PreviewTextAttachment) => void;
framePreview?: boolean;
showTextStatus?: boolean;
}> = ({
block,
onImageClick,
onTextFileClick,
framePreview = false,
showTextStatus = false,
}) => {
}> = ({ block, onImageClick, onTextFileClick, framePreview = false }) => {
const [revealedInlineText, setRevealedInlineText] = useState(false);
const href = getAttachmentHref(block);
const displayName = getAttachmentDisplayName(block);
@@ -644,7 +624,6 @@ export const AttachmentBlock: React.FC<{
frameHref={framePreview ? href : undefined}
downloadName={downloadName}
onPreview={onTextFileClick}
showStatus={showTextStatus}
/>
);
}
@@ -659,7 +638,7 @@ export const AttachmentBlock: React.FC<{
isPlaceholder={!revealedInlineText}
onPreview={() => {
setRevealedInlineText(true);
void onTextFileClick?.({
void onTextFileClick({
content,
fileName: displayName,
mediaType: block.media_type,
@@ -13,18 +13,13 @@ type ReconnectingStatus = Extract<LiveStatusModel, { phase: "reconnecting" }>;
/**
* Syncs with the system clock to produce a live countdown from an
* ISO-8601 deadline. Polls at 100ms so the displayed second flips
* within 100ms of the real transition. Returns 0 when no deadline is
* provided or the deadline has passed.
* within 100ms of the real transition. Returns 0 when the deadline is
* invalid or has passed.
*/
const useDeadlineCountdown = (deadline: string | undefined): number => {
const useDeadlineCountdown = (deadline: string): number => {
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (!deadline) {
setSecondsLeft(0);
return;
}
const targetMs = new Date(deadline).getTime();
if (!Number.isFinite(targetMs)) {
setSecondsLeft(0);
@@ -22,6 +22,7 @@ import { getChatFileURL } from "../../utils/chatAttachments";
import { ChatMessageScroller } from "../ChatMessageScroller";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
import { buildLiveStatus } from "./storyFixtures";
import type { ParsedMessageEntry } from "./types";
// The timeline renders scroller items, so every story needs the scroller
@@ -439,7 +440,15 @@ const defaultArgs: Omit<
organizationId: "organization-id",
urlTransform: defaultUrlTransform,
mcpServers: [],
streamTools: [],
liveStatus: buildLiveStatus(),
subagentStatusOverrides: new Map(),
subagentTitles: new Map(),
subagentVariants: new Map(),
isChatCompleted: true,
showDesktopPreviews: false,
hasActiveStream: false,
isAwaitingFirstStreamChunk: false,
automationNames: { names: new Map(), status: "settled" },
};
@@ -124,8 +124,8 @@ const ChatMessageItem = memo<{
onImplementPlan?: () => Promise<void> | void;
urlTransform: UrlTransform;
mcpServers: readonly TypesGen.MCPServerConfig[];
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
subagentTitles: Map<string, string>;
subagentVariants: Map<string, SubagentVariant>;
showDesktopPreviews?: boolean;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
@@ -424,11 +424,11 @@ type ConversationTimelineProps = {
chatFiles?: readonly TypesGen.ChatFileMetadata[];
initialActiveTurnMaxMessageId?: number;
streamState?: StreamState | null;
streamTools?: readonly MergedTool[];
liveStatus?: LiveStatusModel;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
streamTools: readonly MergedTool[];
liveStatus: LiveStatusModel;
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
subagentTitles: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
subagentVariants: Map<string, SubagentVariant>;
onEditUserMessage?: (
messageId: number,
text: string,
@@ -437,12 +437,12 @@ type ConversationTimelineProps = {
editingMessageId?: number | null;
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
isChatCompleted: boolean;
urlTransform: UrlTransform;
mcpServers: readonly TypesGen.MCPServerConfig[];
showDesktopPreviews?: boolean;
hasActiveStream?: boolean;
isAwaitingFirstStreamChunk?: boolean;
showDesktopPreviews: boolean;
hasActiveStream: boolean;
isAwaitingFirstStreamChunk: boolean;
};
export const ConversationTimeline = memo<ConversationTimelineProps>(
@@ -453,7 +453,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
chatFiles,
initialActiveTurnMaxMessageId,
streamState,
streamTools = [],
streamTools,
liveStatus,
subagentStatusOverrides,
subagentTitles,
@@ -478,14 +478,13 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
const evictedFileIds = deriveEvictedFileIds(parsedMessages, chatFiles);
const renderRows = assignTimelineRows(
displayMessages,
Boolean(liveStatus && shouldRenderLiveAssistant(liveStatus)),
shouldRenderLiveAssistant(liveStatus),
);
// A live turn only reveals its stream blocks once output has accumulated.
// Before that the callout and thinking indicator stand in for the turn.
const showsStreamOutput =
liveStatus !== undefined &&
(liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput);
liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput;
const liveBlocks = showsStreamOutput ? (streamState?.blocks ?? []) : [];
const liveTools = showsStreamOutput ? streamTools : [];
@@ -523,9 +522,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
// handled, and its fallback for mutations that are neither clean appends
// nor clean prepends jumps to the oldest unhandled anchor, so historical
// rows must not be anchors at all.
const hasLiveAssistant = Boolean(
liveStatus && shouldRenderLiveAssistant(liveStatus),
);
const hasLiveAssistant = shouldRenderLiveAssistant(liveStatus);
const anchorUserRowKey =
hasLiveAssistant || isAwaitingFirstStreamChunk
? userRowKeys[userRowKeys.length - 1]
@@ -580,7 +577,6 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
<FileProbeProvider evictedFileIds={evictedFileIds}>
{renderRows.map((row) => {
if (row.type === "live") {
// This row only exists when liveStatus is set.
return (
<MessageScroller.Item key={row.key} messageId={row.key}>
<ChatMessageItem
@@ -646,8 +642,8 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
urlTransform={urlTransform}
isAfterEditingMessage={isAfterEditingMessage}
hideActions={!isUser && !row.isLastInAssistantChain}
hasActiveStream={Boolean(hasActiveStream)}
isAwaitingFirstStreamChunk={Boolean(isAwaitingFirstStreamChunk)}
hasActiveStream={hasActiveStream}
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
isLastMessage={row.isLastMessage}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
@@ -27,117 +27,100 @@ import type { MergedTool, RenderBlock } from "./types";
const ReasoningDisclosure = memo<{
id: string;
text: string;
isStreaming?: boolean;
isStreaming: boolean;
urlTransform: UrlTransform;
thinkingDisplayMode?: ThinkingDisplayMode;
}>(
({
id,
text,
isStreaming = false,
urlTransform,
thinkingDisplayMode: mode = "auto",
}) => {
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
thinkingDisplayMode: ThinkingDisplayMode;
}>(({ id, text, isStreaming, urlTransform, thinkingDisplayMode: mode }) => {
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
// Reset manual override on streaming transitions so
// auto/preview modes collapse when streaming stops.
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
if (prevStreaming !== isStreaming) {
setPrevStreaming(isStreaming);
if (mode === "auto" || mode === "preview") {
setManualToggle(null);
// Reset manual override on streaming transitions so
// auto/preview modes collapse when streaming stops.
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
if (prevStreaming !== isStreaming) {
setPrevStreaming(isStreaming);
if (mode === "auto" || mode === "preview") {
setManualToggle(null);
}
}
const autoExpanded = (() => {
switch (mode) {
case "always_expanded":
return true;
case "always_collapsed":
return false;
case "auto":
case "preview":
return isStreaming;
default: {
const _exhaustive: never = mode;
return _exhaustive;
}
}
})();
const autoExpanded = (() => {
switch (mode) {
case "always_expanded":
return true;
case "always_collapsed":
return false;
case "auto":
case "preview":
return isStreaming;
default: {
const _exhaustive: never = mode;
return _exhaustive;
}
}
})();
const expanded = manualToggle ?? autoExpanded;
const expanded = manualToggle ?? autoExpanded;
const isPreviewConstrained =
mode === "preview" && isStreaming && manualToggle === null;
const isPreviewConstrained =
mode === "preview" && isStreaming && manualToggle === null;
const previewScrollRef = useRef<HTMLDivElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const { visibleText } = useSmoothStreamingText({
fullText: text,
isStreaming,
bypassSmoothing: !isStreaming,
streamKey: id,
});
const displayText = isStreaming ? visibleText : text;
const { title, ariaLabel, body } = getThinkingDisclosureDisplay(displayText, {
isStreaming,
});
const hasText = body.trim().length > 0;
const { visibleText } = useSmoothStreamingText({
fullText: text,
isStreaming,
bypassSmoothing: !isStreaming,
streamKey: id,
});
const displayText = isStreaming ? visibleText : text;
const { title, ariaLabel, body } = getThinkingDisclosureDisplay(
displayText,
{ isStreaming },
);
const hasText = body.trim().length > 0;
// Auto-scroll the preview container to the bottom as new
// thinking content streams in. useLayoutEffect avoids a
// visible frame where content has grown but not scrolled.
const displayTextLength = body.length;
useLayoutEffect(() => {
if (displayTextLength && isPreviewConstrained && previewScrollRef.current) {
previewScrollRef.current.scrollTop =
previewScrollRef.current.scrollHeight;
}
}, [displayTextLength, isPreviewConstrained]);
// Auto-scroll the preview container to the bottom as new
// thinking content streams in. useLayoutEffect avoids a
// visible frame where content has grown but not scrolled.
const displayTextLength = body.length;
useLayoutEffect(() => {
if (
displayTextLength &&
isPreviewConstrained &&
previewScrollRef.current
) {
previewScrollRef.current.scrollTop =
previewScrollRef.current.scrollHeight;
}
}, [displayTextLength, isPreviewConstrained]);
return (
<div data-transcript-row="">
<ToolCall.Root
className="w-full"
status={isStreaming ? "running" : "completed"}
hasContent={hasText}
expanded={expanded}
onExpandedChange={(open) => setManualToggle(open)}
ariaLabel={ariaLabel}
>
<ToolCall.Header
iconName="thinking"
label={title}
showStatus={false}
/>
<ToolCall.Content>
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
return (
<div data-transcript-row="">
<ToolCall.Root
className="w-full"
status={isStreaming ? "running" : "completed"}
hasContent={hasText}
expanded={expanded}
onExpandedChange={(open) => setManualToggle(open)}
ariaLabel={ariaLabel}
>
<ToolCall.Header iconName="thinking" label={title} showStatus={false} />
<ToolCall.Content>
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
{body}
</Response>
</div>
</ToolCall.Content>
</ToolCall.Root>
</div>
);
},
);
{body}
</Response>
</div>
</ToolCall.Content>
</ToolCall.Root>
</div>
);
});
// Runs the smooth-streaming jitter buffer while the turn is live and renders
// the raw text once it is durable, so both shapes render through the same
@@ -196,20 +179,20 @@ export type BlockListProps = {
blocks: readonly RenderBlock[];
tools: readonly MergedTool[];
keyPrefix: string;
isStreaming?: boolean;
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
isStreaming: boolean;
subagentTitles: Map<string, string>;
subagentVariants: Map<string, SubagentVariant>;
showDesktopPreviews?: boolean;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
mcpServers: readonly TypesGen.MCPServerConfig[];
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImageClick: (src: string) => void;
onTextFileClick: (attachment: PreviewTextAttachment) => void;
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
latestAskUserQuestionToolId?: string;
askUserQuestionResponseTextByToolId?: ReadonlyMap<string, string>;
hasUserResponseAfterAskQuestion?: boolean;
hasUserResponseAfterAskQuestion: boolean;
urlTransform: UrlTransform;
};
@@ -222,7 +205,7 @@ export const BlockList: React.FC<BlockListProps> = ({
blocks,
tools,
keyPrefix,
isStreaming = false,
isStreaming,
subagentTitles,
subagentVariants,
showDesktopPreviews,
@@ -235,7 +218,7 @@ export const BlockList: React.FC<BlockListProps> = ({
isChatCompleted,
latestAskUserQuestionToolId,
askUserQuestionResponseTextByToolId,
hasUserResponseAfterAskQuestion = false,
hasUserResponseAfterAskQuestion,
urlTransform,
}) => {
const prefQuery = useQuery(preferenceSettings());
@@ -404,7 +387,6 @@ export const BlockList: React.FC<BlockListProps> = ({
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
framePreview
showTextStatus
/>
);
case "sources":
@@ -61,16 +61,10 @@ const renderUserInlineContent = (blocks: readonly UserInlineRenderBlock[]) => {
export const UserMessageContent: React.FC<{
displayState: MessageDisplayState;
markdown: string;
isEditing?: boolean;
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
}> = ({
displayState,
markdown,
isEditing = false,
onImageClick,
onTextFileClick,
}) => {
isEditing: boolean;
onImageClick: (src: string) => void;
onTextFileClick: (attachment: PreviewTextAttachment) => void;
}> = ({ displayState, markdown, isEditing, onImageClick, onTextFileClick }) => {
return (
<Message className="w-fit max-w-[min(80vw,80%)]">
<MessageContent
@@ -105,7 +99,6 @@ export const UserMessageContent: React.FC<{
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
showTextStatus
/>
))}
</div>
@@ -33,6 +33,7 @@ const getDisplayState = (
parsed: parseMessageContent(message.content),
hideActions: false,
hasActiveStream: false,
isAwaitingFirstStreamChunk: false,
...overrides,
});
@@ -445,6 +446,7 @@ describe("deriveMessageDisplayState", () => {
parsed: parsedContent,
hideActions: false,
hasActiveStream: false,
isAwaitingFirstStreamChunk: false,
}).shouldHide,
).toBe(true);
});
@@ -144,13 +144,13 @@ export const deriveMessageDisplayState = ({
parsed,
hideActions,
hasActiveStream,
isAwaitingFirstStreamChunk = false,
isAwaitingFirstStreamChunk,
}: {
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
hideActions: boolean;
hasActiveStream: boolean;
isAwaitingFirstStreamChunk?: boolean;
isAwaitingFirstStreamChunk: boolean;
}): MessageDisplayState => {
const isUser = message.role === "user";
const userInlineContent = isUser
@@ -62,7 +62,16 @@ const longAdvice = [
const meta: Meta<typeof Tool> = {
title: "pages/AgentsPage/ChatElements/tools/AdvisorTool",
component: Tool,
args: { name: "advisor", organizationId: "organization-id", mcpServers: [] },
args: {
name: "advisor",
organizationId: "organization-id",
mcpServers: [],
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
};
export default meta;
type Story = StoryObj<typeof Tool>;
@@ -105,6 +105,11 @@ const meta: Meta<typeof Tool> = {
name: "ask_user_question",
organizationId: "organization-id",
mcpServers: [],
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
};
@@ -20,7 +20,7 @@ export const CreateWorkspaceTool: React.FC<{
isError: boolean;
errorMessage?: string;
buildId?: string;
created?: boolean;
created: boolean;
labelOverride?: string;
}> = ({
workspaceName,
@@ -29,7 +29,7 @@ export const CreateWorkspaceTool: React.FC<{
isError,
errorMessage,
buildId,
created = true,
created,
labelOverride,
}) => {
const isRunning = status === "running";
@@ -44,7 +44,7 @@ export const CreateWorkspaceTool: React.FC<{
? labelOverride
: isError
? `Failed to create ${wsName || "workspace"}`
: created === false
: !created
? `Workspace ${wsName} already exists`
: wsName
? `Created ${wsName}`
@@ -28,7 +28,7 @@ export const EditFilesTool: React.FC<{
status: ToolStatus;
isError: boolean;
errorMessage?: string;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode: TypesGen.AgentDisplayMode;
}> = ({ files, diffs, status, isError, errorMessage, codeDiffDisplayMode }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -56,7 +56,7 @@ export const EditFilesTool: React.FC<{
return (
<ToolCall.Root
key={`${codeDiffDisplayMode ?? "auto"}:${EDIT_FILES_AUTO_DISPLAY_STATE}`}
key={`${codeDiffDisplayMode}:${EDIT_FILES_AUTO_DISPLAY_STATE}`}
className="w-full"
status={status}
isError={isError}
@@ -14,6 +14,9 @@ const meta: Meta<typeof ExecuteTool> = {
args: {
status: "completed",
isError: false,
errorText: "",
isBackgrounded: false,
shellToolDisplayMode: "auto",
transcriptBlocks: [],
},
};
@@ -141,6 +144,7 @@ export const ConnectionError: Story = {
status: "error",
isError: true,
shellToolDisplayMode: "auto",
errorText: stoppedWorkspaceError,
transcriptBlocks: [{ kind: "error", text: stoppedWorkspaceError }],
},
};
@@ -151,6 +155,7 @@ export const OutputWithError: Story = {
command: "go test ./...",
status: "completed",
isBackgrounded: true,
errorText: "command timed out after 10s",
transcriptBlocks: [
{
kind: "output",
@@ -13,6 +13,9 @@ const renderTool = (props: {
command="make build"
transcriptBlocks={[]}
isError={false}
errorText=""
isBackgrounded={false}
shellToolDisplayMode="auto"
{...props}
/>
</TooltipProvider>,
@@ -31,14 +31,14 @@ type ExecuteToolProps = {
transcriptBlocks: readonly ExecuteTranscriptBlock[];
status: ToolStatus;
isError: boolean;
errorText?: string;
errorText: string;
durationMs?: number;
isBackgrounded?: boolean;
isBackgrounded: boolean;
killedBySignal?: "kill" | "terminate";
modelIntent?: string;
parsedCommands?: readonly string[][];
startedAt?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
shellToolDisplayMode: TypesGen.AgentDisplayMode;
};
export const ExecuteTool: React.FC<ExecuteToolProps> = ({
@@ -48,7 +48,7 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
isError,
errorText,
durationMs,
isBackgrounded = false,
isBackgrounded,
killedBySignal,
modelIntent,
parsedCommands,
@@ -81,7 +81,7 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
return (
<ToolCall.Root
key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
key={`${shellToolDisplayMode}:${autoDisplayState}`}
className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start rounded-md bg-surface-primary font-sans font-normal text-xs leading-5"
status={status}
isError={isError}
@@ -6,7 +6,15 @@ const PROCESS_ID = "376b2458-e318-4442-8b87-51a0f9727f0e";
const meta: Meta<typeof Tool> = {
title: "components/ai-elements/tool/ProcessKilledIndicator",
component: Tool,
args: { organizationId: "organization-id", mcpServers: [] },
args: {
organizationId: "organization-id",
mcpServers: [],
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
};
export default meta;
type Story = StoryObj<typeof Tool>;
@@ -30,12 +30,12 @@ type ProcessOutputToolProps = {
* affects label tense and signal badges; the row must not animate for
* it, because the snapshot never updates once the poll completes.
*/
processRunning?: boolean;
processRunning: boolean;
exitCode: number | null;
isError: boolean;
errorMessage?: string;
killedBySignal?: "kill" | "terminate";
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
shellToolDisplayMode: TypesGen.AgentDisplayMode;
};
const getProcessOutputLabel = ({
@@ -50,9 +50,7 @@ const getProcessOutputLabel = ({
isFailed: boolean;
}): string => {
const trimmedCommand = command?.trim() ?? "";
const intent = modelIntent
? sanitizeExecuteModelIntent(modelIntent, trimmedCommand)
: "";
const intent = sanitizeExecuteModelIntent(modelIntent, trimmedCommand);
if (intent) {
return intent;
}
@@ -70,7 +68,7 @@ export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = ({
command,
modelIntent,
status,
processRunning = false,
processRunning,
exitCode,
isError,
errorMessage,
@@ -93,7 +91,7 @@ export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = ({
return (
<ToolCall.Root
key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
key={`${shellToolDisplayMode}:${autoDisplayState}`}
className="group/proc w-full"
status={status}
isError={isError}
@@ -10,6 +10,11 @@ const meta: Meta<typeof Tool> = {
name: "process_signal",
organizationId: "organization-id",
mcpServers: [],
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
};
export default meta;
@@ -45,6 +45,11 @@ const meta: Meta<typeof Tool> = {
name: "propose_plan",
organizationId: "organization-id",
mcpServers: [],
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
parameters: {
reactRouter: reactRouterParameters({ routing: { path: "/" } }),
@@ -22,8 +22,8 @@ const getReadFileItem = (tool: MergedTool): ReadFileItem => ({
export const ReadFilesTool: React.FC<{
tools: readonly MergedTool[];
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
}> = ({ tools, expanded, onExpandedChange }) => {
const [expandedFileIDs, setExpandedFileIDs] = useState<ReadonlySet<string>>(
new Set(),
@@ -60,7 +60,7 @@ const SUBAGENT_VERBS: Record<
* the rendering logic for the three label variants readable.
*/
function getSubagentLabel(
showDesktopPreview: boolean | undefined,
showDesktopPreview: boolean,
toolStatus: ToolStatus,
descriptor: SubagentDescriptor,
title: string,
@@ -120,15 +120,15 @@ const SubagentStatusIcon: React.FC<{
toolStatus: ToolStatus;
isError: boolean;
isTimeout: boolean;
iconKind?: SubagentDescriptor["iconKind"];
showDesktopPreview?: boolean;
iconKind: SubagentDescriptor["iconKind"];
showDesktopPreview: boolean;
}> = ({
subagentStatus,
toolStatus,
isError,
isTimeout,
iconKind = "bot",
showDesktopPreview = false,
iconKind,
showDesktopPreview,
}) => {
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
const DefaultIcon = iconKind === "monitor" ? MonitorIcon : BotIcon;
@@ -168,9 +168,9 @@ export const SubagentTool: React.FC<{
report?: string;
toolStatus: ToolStatus;
isError: boolean;
isTimeout?: boolean;
isTimeout: boolean;
/** Show an inline VNC desktop preview (for computer-use subagents). */
showDesktopPreview?: boolean;
showDesktopPreview: boolean;
/** File ID for a completed recording (shown after tool completes). */
recordingFileId?: string;
/** File ID for the JPEG thumbnail of a completed recording. */
@@ -186,7 +186,7 @@ export const SubagentTool: React.FC<{
report,
toolStatus,
isError,
isTimeout = false,
isTimeout,
showDesktopPreview,
recordingFileId,
thumbnailFileId,
@@ -42,6 +42,11 @@ const meta: Meta<typeof Tool> = {
name: "execute",
args: { command: executeCommand },
status: "completed",
isError: false,
subagentTitles: new Map(),
subagentVariants: new Map(),
shellToolDisplayMode: "auto",
codeDiffDisplayMode: "auto",
},
parameters: {
reactRouter: reactRouterParameters({
@@ -2982,6 +2987,12 @@ export const AllToolIconsTranscript: Story = {
organizationId="organization-id"
mcpServers={[]}
urlTransform={defaultUrlTransform}
isStreaming={false}
subagentTitles={new Map()}
subagentVariants={new Map()}
hasUserResponseAfterAskQuestion={false}
onImageClick={fn()}
onTextFileClick={fn()}
/>
{allToolShowcaseItems.map((tool, index) => (
<Tool
@@ -2992,11 +3003,12 @@ export const AllToolIconsTranscript: Story = {
status={tool.status ?? "completed"}
args={tool.args}
result={tool.result}
isError={tool.isError}
isError={tool.isError ?? false}
killedBySignal={tool.killedBySignal}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
subagentVariants={tool.subagentVariants}
subagentTitles={new Map()}
subagentVariants={tool.subagentVariants ?? new Map()}
shellToolDisplayMode="always_collapsed"
codeDiffDisplayMode="always_collapsed"
showDesktopPreviews={false}
@@ -3067,11 +3079,12 @@ export const PolicyBadgeCoversEveryRenderer: Story = {
status={tool.status ?? "completed"}
args={tool.args}
result={tool.result}
isError={tool.isError}
isError={tool.isError ?? false}
killedBySignal={tool.killedBySignal}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
subagentVariants={tool.subagentVariants}
subagentTitles={new Map()}
subagentVariants={tool.subagentVariants ?? new Map()}
hookRewritten
shellToolDisplayMode="always_collapsed"
codeDiffDisplayMode="always_collapsed"
@@ -73,6 +73,11 @@ describe("Tool workspace lifecycle rows", () => {
result={isRunning ? undefined : { build_id: buildId }}
organizationId="organization-id"
mcpServers={[]}
isError={false}
subagentTitles={new Map()}
subagentVariants={new Map()}
shellToolDisplayMode="auto"
codeDiffDisplayMode="auto"
/>
</ChatWorkspaceContext>
</QueryClientProvider>,
@@ -117,9 +122,37 @@ describe("Tool manage_automations label", () => {
result={{ error: "unknown action" }}
organizationId="organization-id"
mcpServers={[]}
subagentTitles={new Map()}
subagentVariants={new Map()}
shellToolDisplayMode="auto"
codeDiffDisplayMode="auto"
/>,
);
screen.getByText("Manage automations");
});
});
describe("Tool generic rows", () => {
it("falls back to the tool name when the model intent is whitespace", () => {
renderComponent(
<QueryClientProvider client={createTestQueryClient()}>
<Tool
name="custom_tool"
organizationId="organization-id"
mcpServers={[]}
status="completed"
args={{ query: "value" }}
isError={false}
modelIntent=" "
subagentTitles={new Map()}
subagentVariants={new Map()}
shellToolDisplayMode="auto"
codeDiffDisplayMode="auto"
/>
</QueryClientProvider>,
);
expect(screen.getByRole("button")).toHaveAccessibleName("custom_tool");
});
});
@@ -72,14 +72,14 @@ type ToolProps = Omit<React.ComponentProps<"div">, "children"> & {
result?: unknown;
/** Streamed advisor reasoning, present only while the advisor runs. */
reasoning?: string;
isError?: boolean;
isError: boolean;
/** Set when the server persisted the result as {data, mime_type, text}. */
isMedia?: boolean;
killedBySignal?: "kill" | "terminate";
/** Maps sub-agent chat IDs to their titles, built from transcript metadata. */
subagentTitles?: Map<string, string>;
subagentTitles: Map<string, string>;
/** Maps sub-agent chat IDs to their normalized variants. */
subagentVariants?: Map<string, SubagentVariant>;
subagentVariants: Map<string, SubagentVariant>;
/** When false, suppresses inline VNC previews while still
* allowing the MonitorIcon variant to render. */
showDesktopPreviews?: boolean;
@@ -100,8 +100,8 @@ type ToolProps = Omit<React.ComponentProps<"div">, "children"> & {
parsedCommands?: readonly string[][];
startedAt?: string;
hookRewritten?: boolean;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
shellToolDisplayMode: TypesGen.AgentDisplayMode;
codeDiffDisplayMode: TypesGen.AgentDisplayMode;
};
// Props passed to each tool-specific renderer function. Each renderer
@@ -117,8 +117,8 @@ type ToolRendererProps = {
isError: boolean;
isMedia?: boolean;
killedBySignal?: "kill" | "terminate";
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
subagentTitles: Map<string, string>;
subagentVariants: Map<string, SubagentVariant>;
showDesktopPreviews?: boolean;
subagentStatusOverrides?: Map<string, string>;
onImplementPlan?: () => Promise<void> | void;
@@ -131,8 +131,8 @@ type ToolRendererProps = {
modelIntent?: string;
parsedCommands?: readonly string[][];
startedAt?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
shellToolDisplayMode: TypesGen.AgentDisplayMode;
codeDiffDisplayMode: TypesGen.AgentDisplayMode;
};
// ---------------------------------------------------------------------------
@@ -460,7 +460,7 @@ const SubagentRenderer: React.FC<ToolRendererProps> = ({
args: parsedArgs ?? args,
result: rec ?? result,
});
const inferredVariant = chatId ? subagentVariants?.get(chatId) : undefined;
const inferredVariant = chatId ? subagentVariants.get(chatId) : undefined;
const descriptor = getSubagentDescriptor({
name,
args: parsedArgs ?? args,
@@ -492,7 +492,7 @@ const SubagentRenderer: React.FC<ToolRendererProps> = ({
descriptor.fallbackTitle.charAt(0).toUpperCase() +
descriptor.fallbackTitle.slice(1);
if (chatId) {
const mappedTitle = subagentTitles?.get(chatId);
const mappedTitle = subagentTitles.get(chatId);
if (mappedTitle) {
title = mappedTitle;
}
@@ -1018,9 +1018,7 @@ const GenericToolRenderer: React.FC<ToolRendererProps> = ({
iconUrl={mcpServer?.icon_url}
serverName={mcpServer?.display_name}
label={
modelIntent ? (
formatModelIntentLabel(modelIntent)
) : (
formatModelIntentLabel(modelIntent) || (
<ToolLabel
name={name}
args={args}
@@ -1227,7 +1225,7 @@ export const Tool = memo(
args,
result,
reasoning,
isError = false,
isError,
isMedia,
killedBySignal,
subagentTitles,
@@ -86,7 +86,7 @@ const useToolCallContext = () => {
/**
* Props for {@link ToolCall.Root}.
*
* The root can be controlled with `view` or `expanded`, or uncontrolled
* The root can be controlled with `expanded`, or uncontrolled
* with `defaultView` and `defaultExpanded`. When both uncontrolled props
* are provided, `defaultView` wins because it can represent the more
* specific `preview` state.
@@ -102,14 +102,12 @@ type ToolCallRootProps = Omit<React.ComponentProps<"div">, "children"> & {
status: ToolStatus;
isError?: boolean;
errorMessage?: string;
hasContent?: boolean;
hasContent: boolean;
defaultExpanded?: boolean;
defaultView?: ToolCallView;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onViewChange?: (view: ToolCallView) => void;
ariaLabel?: ToolCallAriaLabel;
view?: ToolCallView;
};
/**
@@ -124,27 +122,24 @@ const Root: React.FC<ToolCallRootProps> = ({
status,
isError = false,
errorMessage,
hasContent = true,
hasContent,
defaultExpanded = false,
defaultView,
expanded: expandedProp,
onExpandedChange,
onViewChange,
ariaLabel,
className,
view: viewProp,
...divProps
}) => {
const [uncontrolledView, setUncontrolledView] = useState<ToolCallView>(
defaultView ?? (defaultExpanded ? "expanded" : "collapsed"),
);
const controlledView =
viewProp ??
(expandedProp === undefined
expandedProp === undefined
? undefined
: expandedProp
? "expanded"
: "collapsed");
: "collapsed";
const view = controlledView ?? uncontrolledView;
const expanded = view !== "collapsed";
const collapsible = hasContent;
@@ -155,7 +150,6 @@ const Root: React.FC<ToolCallRootProps> = ({
if (controlledView === undefined) {
setUncontrolledView(nextView);
}
onViewChange?.(nextView);
onExpandedChange?.(nextView !== "collapsed");
};
@@ -273,20 +267,15 @@ const LeadingIcon: React.FC<ToolCallLeadingIconProps> = ({
type ToolCallLabelProps = {
children: React.ReactNode;
className?: string;
shimmerWhenActive?: boolean;
};
const Label: React.FC<ToolCallLabelProps> = ({
children,
className,
shimmerWhenActive = true,
}) => {
const Label: React.FC<ToolCallLabelProps> = ({ children, className }) => {
const { active } = useToolCallContext();
const labelClassName = cn(
"min-w-0 truncate text-[13px] leading-6",
className,
);
if (active && shimmerWhenActive && typeof children === "string") {
if (active && typeof children === "string") {
return (
<Shimmer as="span" className={labelClassName}>
{children}
@@ -397,7 +386,6 @@ type ToolCallHeaderProps = {
secondaryLabel?: React.ReactNode;
trailing?: React.ReactNode;
showStatus?: boolean;
headerClassName?: string;
};
/**
@@ -417,10 +405,9 @@ const Header: React.FC<ToolCallHeaderProps> = ({
secondaryLabel,
trailing,
showStatus = true,
headerClassName,
}) => {
return (
<HeaderButton className={headerClassName}>
<HeaderButton>
<LeadingIcon name={iconName} iconUrl={iconUrl} serverName={serverName} />
<Label>{label}</Label>
{secondaryLabel}
@@ -10,7 +10,7 @@ type WorkspaceLifecycleToolProps = {
workspaceName: string;
isError: boolean;
errorMessage?: string;
noBuild?: boolean;
noBuild: boolean;
labelOverride?: string;
};
@@ -27,7 +27,7 @@ export const WriteFileTool: React.FC<{
status: ToolStatus;
isError: boolean;
errorMessage?: string;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode: TypesGen.AgentDisplayMode;
}> = ({ path, diff, status, isError, errorMessage, codeDiffDisplayMode }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -52,7 +52,7 @@ export const WriteFileTool: React.FC<{
return (
<ToolCall.Root
key={`${codeDiffDisplayMode ?? "auto"}:${WRITE_FILE_AUTO_DISPLAY_STATE}`}
key={`${codeDiffDisplayMode}:${WRITE_FILE_AUTO_DISPLAY_STATE}`}
className="w-full"
status={status}
isError={isError}
@@ -7,7 +7,6 @@ import {
describe("resolveAgentDisplayState", () => {
it("resolves auto and explicit display modes", () => {
expect(resolveAgentDisplayState(undefined, "preview")).toBe("preview");
expect(resolveAgentDisplayState("auto", "collapsed")).toBe("collapsed");
expect(resolveAgentDisplayState("auto", "expanded")).toBe("expanded");
expect(resolveAgentDisplayState("always_expanded", "collapsed")).toBe(
@@ -4,11 +4,10 @@ import type { ToolCallView } from "./ToolCall";
export type AgentDisplayState = ToolCallView;
export const resolveAgentDisplayState = (
mode: AgentDisplayMode | undefined,
mode: AgentDisplayMode,
autoState: AgentDisplayState,
): AgentDisplayState => {
switch (mode) {
case undefined:
case "auto":
return autoState;
case "always_expanded":