mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
refactor(site/src/pages/AgentsPage): require always-passed timeline and tool props (#29891)
This commit is contained in:
@@ -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>;
|
||||
|
||||
+5
@@ -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}
|
||||
|
||||
+9
-1
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user