refactor(site): address plan-mode frontend review feedback (#24426)

> This PR was authored by Mux on behalf of Mike.

Address all 8 frontend review comments from DanielleMaywood on #24236.

## Changes

**Low-risk cleanups:**
- Inlined module-level `toolBadgeClassName` in `AgentChatInput.tsx`
- Inlined `planModeInvisibleCharWarning` variable in
`PlanModeInstructionsSettings.tsx`
- Replaced loose `nilUUID` and `clearPlanMode` sentinels in `chats.ts`
with a `toChatPlanModePayload()` helper and inline nil UUID. Page-level
mutation callers now pass `undefined` to clear plan mode instead of
`""`.

**Tool state management:**
- Replaced manual `isSubmitting`/`setIsSubmitting` in
`ProposePlanTool.tsx` with `useMutation`
- Overhauled `AskUserQuestionTool.tsx`: replaced `cloneAnswer` with
`structuredClone`, replaced `setIsSubmitting` with `useMutation`,
extracted `QuestionStep`, `QuestionOption`, `OtherQuestionOption`, and
`AnsweredQuestionText` components

**Timeline complexity reduction:**
- Extracted `deriveMessageDisplayState()` into `messageHelpers.ts`
(pure, testable)
- Extracted user-message rendering into `UserMessageContent.tsx`
- Reduced `ConversationTimeline.tsx` by ~285 lines

## Validation
- TypeScript: pass
- Lint (Biome + ESLint): pass
- React Compiler: 247 functions, 0 diagnostics
- Storybook tests: 42/42 pass (ProposePlanTool 9, ConversationTimeline
22, AskUserQuestionTool 11)
This commit is contained in:
Michael Suchacz
2026-04-16 15:16:29 +02:00
committed by GitHub
parent 4064b602de
commit 6bb44447d4
8 changed files with 701 additions and 501 deletions
+16 -8
View File
@@ -211,7 +211,6 @@ export const cancelChatListRefetches = (queryClient: QueryClient) => {
};
const DEFAULT_CHAT_PAGE_LIMIT = 50;
const nilUUID = "00000000-0000-0000-0000-000000000000";
type UpdateChatWorkspaceVariables = {
chatId: string;
@@ -220,10 +219,17 @@ type UpdateChatWorkspaceVariables = {
type UpdateChatPlanModeVariables = {
chatId: string;
planMode?: ChatPlanModeOrClear;
planMode?: TypesGen.ChatPlanMode;
};
const clearPlanMode = "" satisfies ChatPlanModeOrClear;
const CLEAR_PLAN_MODE_WIRE_VALUE = "" satisfies ChatPlanModeOrClear;
const toChatPlanModePayload = (
planMode: TypesGen.ChatPlanMode | undefined,
): ChatPlanModeOrClear => {
// The API expects an empty string on the wire to clear plan mode.
return planMode ?? CLEAR_PLAN_MODE_WIRE_VALUE;
};
export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => {
const limit = DEFAULT_CHAT_PAGE_LIMIT;
@@ -407,7 +413,7 @@ export const unarchiveChat = (queryClient: QueryClient) => ({
export const updateChatPlanMode = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, planMode }: UpdateChatPlanModeVariables) =>
API.experimental.updateChat(chatId, {
plan_mode: planMode ?? clearPlanMode,
plan_mode: toChatPlanModePayload(planMode),
}),
onMutate: async ({ chatId, planMode }: UpdateChatPlanModeVariables) => {
await queryClient.cancelQueries({
@@ -421,16 +427,15 @@ export const updateChatPlanMode = (queryClient: QueryClient) => ({
const previousChat = queryClient.getQueryData<TypesGen.Chat>(
chatKey(chatId),
);
const nextPlanMode = planMode === clearPlanMode ? undefined : planMode;
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId ? { ...chat, plan_mode: nextPlanMode } : chat,
chat.id === chatId ? { ...chat, plan_mode: planMode } : chat,
),
);
if (previousChat) {
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), {
...previousChat,
plan_mode: nextPlanMode,
plan_mode: planMode,
});
}
return { previousChat };
@@ -466,7 +471,10 @@ export const updateChatPlanMode = (queryClient: QueryClient) => ({
export const updateChatWorkspace = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, workspaceId }: UpdateChatWorkspaceVariables) =>
API.experimental.updateChat(chatId, {
workspace_id: workspaceId ?? nilUUID,
workspace_id:
workspaceId ??
// The API uses the nil UUID to clear the workspace association.
"00000000-0000-0000-0000-000000000000",
}),
onMutate: async ({ chatId, workspaceId }: UpdateChatWorkspaceVariables) => {
await queryClient.cancelQueries({
@@ -171,16 +171,16 @@ type ToolBadgeData =
| ({ kind: "attached-workspace" } & AttachedWorkspaceInfo)
| { kind: "mcp"; server: TypesGen.MCPServerConfig };
const toolBadgeClassName =
"inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary";
const ToolBadge: FC<{
badge: ToolBadgeData;
onRemoveWorkspace?: () => void;
onRemoveMcp?: (serverId: string) => void;
className?: string;
}> = ({ badge, onRemoveWorkspace, onRemoveMcp, className }) => {
const badgeCls = cn(toolBadgeClassName, className);
const badgeCls = cn(
"inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary",
className,
);
if (badge.kind === "attached-workspace") {
return (
@@ -958,7 +958,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
/>
)}
{planModeEnabled && (
<span className={toolBadgeClassName}>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary">
<PencilIcon className="size-3" />
Planning
</span>
@@ -1,9 +1,8 @@
import { FileTextIcon, PencilIcon } from "lucide-react";
import { PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
memo,
useEffect,
useLayoutEffect,
useRef,
useState,
@@ -18,12 +17,6 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import {
decodeInlineTextAttachment,
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import {
ConversationItem,
Message,
@@ -33,9 +26,9 @@ import {
Tool,
} from "../ChatElements";
import { WebSearchSources } from "../ChatElements/tools";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import { deriveMessageDisplayState } from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
import type {
@@ -44,6 +37,7 @@ import type {
ParsedMessageEntry,
RenderBlock,
} from "./types";
import { FileBlock, UserMessageContent } from "./UserMessageContent";
const getChatMessageTextContent = (
content: readonly TypesGen.ChatMessagePart[] | undefined,
@@ -123,136 +117,6 @@ const SmoothedResponse = memo<{
);
});
const InlineTextAttachmentButton: FC<{
content: string;
onPreview?: (content: string) => void;
isPlaceholder?: boolean;
}> = ({ content, onPreview, isPlaceholder }) => {
return (
<button
type="button"
aria-label="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-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={(e) => {
e.stopPropagation();
onPreview?.(content);
}}
>
<FileTextIcon className="size-icon-sm shrink-0 text-content-secondary" />
<span
className={cn(
"line-clamp-2 min-w-0 text-content-secondary",
isPlaceholder ? "text-sm" : "font-mono text-xs",
)}
>
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
</span>
</button>
);
};
const TextAttachmentButton: FC<{
fileId: string;
onPreview?: (content: string) => void;
}> = ({ fileId, onPreview }) => {
const [content, setContent] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return (
<InlineTextAttachmentButton
content={content ?? "Pasted text"}
isPlaceholder={content === null}
onPreview={async () => {
if (content !== null) {
onPreview?.(content);
return;
}
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
let fetchedContent: string;
try {
fetchedContent = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
} catch (err) {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
if (err instanceof Error && err.name === "AbortError") {
return;
}
console.error("Failed to load text attachment:", err);
return;
}
if (controllerRef.current === controller) {
controllerRef.current = null;
}
setContent(fetchedContent);
onPreview?.(fetchedContent);
}}
/>
);
};
type FileRenderBlock = Extract<RenderBlock, { type: "file" }>;
const FileBlock: FC<{
block: FileRenderBlock;
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
}> = ({ block, onImageClick, onTextFileClick }) => {
if (block.media_type === "text/plain") {
if (block.file_id) {
return (
<TextAttachmentButton
fileId={block.file_id}
onPreview={onTextFileClick}
/>
);
}
if (block.data != null) {
return (
<InlineTextAttachmentButton
content={decodeInlineTextAttachment(block.data)}
onPreview={onTextFileClick}
/>
);
}
}
if (!block.media_type.startsWith("image/")) {
return null;
}
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
<button
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(e) => {
e.stopPropagation();
onImageClick?.(src);
}}
>
<ImageThumbnail
previewUrl={src}
name="Attached image"
className="cursor-pointer transition-opacity hover:opacity-80"
/>
</button>
);
};
// Shared block renderer used by both ChatMessageItem (historical
// messages) and StreamingOutput (live stream). Encapsulates the
// response / thinking / tool / file / sources switch so both
@@ -522,67 +386,19 @@ const ChatMessageItem = memo<{
const isUser = message.role === "user";
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewText, setPreviewText] = useState<string | null>(null);
if (
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
parsed.markdown === "" &&
parsed.reasoning === ""
) {
const displayState = deriveMessageDisplayState({
message,
parsed,
hideActions,
});
if (displayState.shouldHide) {
return null;
}
// Hide messages that consist entirely of provider-executed
// tool results. The parser skips these parts, so the parsed
// output is empty and would show a "no renderable content"
// fallback.
const parts = message.content ?? [];
if (
parts.length > 0 &&
parts.every((p) => p.type === "tool-result" && p.provider_executed)
) {
return null;
}
// Hide messages that consist entirely of context-file
// and/or skill parts. These are metadata for the context
// indicator, not conversation content.
if (
parts.length > 0 &&
parts.every((p) => p.type === "context-file" || p.type === "skill")
) {
return null;
}
const hasRenderableContent =
parsed.blocks.length > 0 ||
parsed.tools.length > 0 ||
parsed.sources.length > 0;
// Pre-compute the inline content for user messages so we
// avoid a filter + map inside the JSX return path.
const userInlineContent = isUser
? parsed.blocks.filter(
(
b,
): b is
| Extract<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }> =>
b.type === "response" || b.type === "file-reference",
)
: [];
const userFileBlocks = isUser
? parsed.blocks.filter(
(b): b is Extract<RenderBlock, { type: "file" }> => b.type === "file",
)
: [];
const hasUserMessageBody =
userInlineContent.length > 0 || Boolean(parsed.markdown?.trim());
const hasFileBlocks = userFileBlocks.length > 0;
const hasCopyableContent = Boolean(parsed.markdown.trim());
const needsAssistantBottomSpacer =
!hideActions &&
!isUser &&
!hasCopyableContent &&
(Boolean(parsed.reasoning) || parsed.sources.length > 0);
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
@@ -596,74 +412,14 @@ const ChatMessageItem = memo<{
>
<ConversationItem {...conversationItemProps}>
{isUser ? (
<Message className="w-full max-w-none">
<MessageContent
className={cn(
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
editingMessageId === message.id &&
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
fadeFromBottom && "relative overflow-hidden",
)}
style={
fadeFromBottom
? { maxHeight: "var(--clip-h, none)" }
: undefined
}
>
<div className="flex flex-col gap-1.5">
{(hasUserMessageBody || hasFileBlocks) && (
<div className="flex items-start gap-2">
{hasUserMessageBody && (
<span className="min-w-0 flex-1">
{userInlineContent.length > 0
? userInlineContent.map((block, i) =>
block.type === "response" ? (
<Fragment key={i}>{block.text}</Fragment>
) : (
<FileReferenceChip
key={i}
fileName={block.file_name}
startLine={block.start_line}
endLine={block.end_line}
className="mx-1"
/>
),
)
: parsed.markdown || ""}
</span>
)}
</div>
)}
{hasFileBlocks && (
<div
className={cn(
hasUserMessageBody && "mt-2",
"flex flex-wrap gap-2",
)}
>
{userFileBlocks.map((block, i) => (
<FileBlock
key={`user-file-${block.file_id ?? i}`}
block={block}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
/>
))}
</div>
)}
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{
opacity: "var(--fade-opacity, 0)",
background:
"linear-gradient(to top, hsl(var(--surface-secondary)), transparent)",
}}
/>
)}
</div>
</MessageContent>
</Message>
<UserMessageContent
displayState={displayState}
markdown={parsed.markdown}
isEditing={editingMessageId === message.id}
fadeFromBottom={fadeFromBottom}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
/>
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
@@ -703,12 +459,13 @@ const ChatMessageItem = memo<{
)}
</ConversationItem>
{!hideActions &&
(hasCopyableContent || (isUser && onEditUserMessage)) && (
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
<div
className="mt-0.5 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100"
data-testid="message-actions"
>
{hasCopyableContent && (
{displayState.hasCopyableContent && (
<CopyButton
text={parsed.markdown}
label="Copy message"
@@ -742,7 +499,7 @@ const ChatMessageItem = memo<{
{/* Spacer for assistant messages without an action bar
(e.g. reasoning-only or sources-only) so they have
consistent bottom padding before the next user bubble. */}
{needsAssistantBottomSpacer && <div className="min-h-6" />}
{displayState.needsAssistantBottomSpacer && <div className="min-h-6" />}
{previewImage && (
<ImageLightbox
src={previewImage}
@@ -0,0 +1,235 @@
import { FileTextIcon } from "lucide-react";
import { type FC, Fragment, useEffect, useRef, useState } from "react";
import { cn } from "#/utils/cn";
import {
decodeInlineTextAttachment,
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import { Message, MessageContent } from "../ChatElements";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
import type {
MessageDisplayState,
UserFileRenderBlock,
UserInlineRenderBlock,
} from "./messageHelpers";
const InlineTextAttachmentButton: FC<{
content: string;
onPreview?: (content: string) => void;
isPlaceholder?: boolean;
}> = ({ content, onPreview, isPlaceholder }) => {
return (
<button
type="button"
aria-label="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-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={(event) => {
event.stopPropagation();
onPreview?.(content);
}}
>
<FileTextIcon className="size-icon-sm shrink-0 text-content-secondary" />
<span
className={cn(
"line-clamp-2 min-w-0 text-content-secondary",
isPlaceholder ? "text-sm" : "font-mono text-xs",
)}
>
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
</span>
</button>
);
};
const TextAttachmentButton: FC<{
fileId: string;
onPreview?: (content: string) => void;
}> = ({ fileId, onPreview }) => {
const [content, setContent] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return (
<InlineTextAttachmentButton
content={content ?? "Pasted text"}
isPlaceholder={content === null}
onPreview={async () => {
if (content !== null) {
onPreview?.(content);
return;
}
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
let fetchedContent: string;
try {
fetchedContent = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
} catch (error) {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Failed to load text attachment:", error);
return;
}
if (controllerRef.current === controller) {
controllerRef.current = null;
}
setContent(fetchedContent);
onPreview?.(fetchedContent);
}}
/>
);
};
export const FileBlock: FC<{
block: UserFileRenderBlock;
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
}> = ({ block, onImageClick, onTextFileClick }) => {
if (block.media_type === "text/plain") {
if (block.file_id) {
return (
<TextAttachmentButton
fileId={block.file_id}
onPreview={onTextFileClick}
/>
);
}
if (block.data != null) {
return (
<InlineTextAttachmentButton
content={decodeInlineTextAttachment(block.data)}
onPreview={onTextFileClick}
/>
);
}
}
if (!block.media_type.startsWith("image/")) {
return null;
}
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
<button
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
onImageClick?.(src);
}}
>
<ImageThumbnail
previewUrl={src}
name="Attached image"
className="cursor-pointer transition-opacity hover:opacity-80"
/>
</button>
);
};
const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => {
if (block.type === "response") {
return <Fragment key={index}>{block.text}</Fragment>;
}
return (
<FileReferenceChip
key={index}
fileName={block.file_name}
startLine={block.start_line}
endLine={block.end_line}
className="mx-1"
/>
);
};
export const UserMessageContent: FC<{
displayState: MessageDisplayState;
markdown: string;
isEditing?: boolean;
fadeFromBottom?: boolean;
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
}> = ({
displayState,
markdown,
isEditing = false,
fadeFromBottom = false,
onImageClick,
onTextFileClick,
}) => {
return (
<Message className="w-full max-w-none">
<MessageContent
className={cn(
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
isEditing &&
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
fadeFromBottom && "relative overflow-hidden",
)}
style={
fadeFromBottom ? { maxHeight: "var(--clip-h, none)" } : undefined
}
>
<div className="flex flex-col gap-1.5">
{(displayState.hasUserMessageBody || displayState.hasFileBlocks) && (
<div className="flex items-start gap-2">
{displayState.hasUserMessageBody && (
<span className="min-w-0 flex-1">
{displayState.userInlineContent.length > 0
? displayState.userInlineContent.map((block, index) =>
renderUserInlineBlock(block, index),
)
: markdown || ""}
</span>
)}
</div>
)}
{displayState.hasFileBlocks && (
<div
className={cn(
displayState.hasUserMessageBody && "mt-2",
"flex flex-wrap gap-2",
)}
>
{displayState.userFileBlocks.map((block, index) => (
<FileBlock
key={`user-file-${block.file_id ?? index}`}
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
/>
))}
</div>
)}
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{
opacity: "var(--fade-opacity, 0)",
background:
"linear-gradient(to top, hsl(var(--surface-secondary)), transparent)",
}}
/>
)}
</div>
</MessageContent>
</Message>
);
};
@@ -0,0 +1,85 @@
import type * as TypesGen from "#/api/typesGenerated";
import type { ParsedMessageContent, RenderBlock } from "./types";
export type UserInlineRenderBlock =
| Extract<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }>;
export type UserFileRenderBlock = Extract<RenderBlock, { type: "file" }>;
export type MessageDisplayState = {
shouldHide: boolean;
userInlineContent: UserInlineRenderBlock[];
userFileBlocks: UserFileRenderBlock[];
hasUserMessageBody: boolean;
hasFileBlocks: boolean;
hasCopyableContent: boolean;
needsAssistantBottomSpacer: boolean;
};
const isUserInlineRenderBlock = (
block: RenderBlock,
): block is UserInlineRenderBlock =>
block.type === "response" || block.type === "file-reference";
const isUserFileRenderBlock = (
block: RenderBlock,
): block is UserFileRenderBlock => block.type === "file";
const isProviderToolResultOnlyMessage = (
parts: readonly TypesGen.ChatMessagePart[],
): boolean =>
parts.length > 0 &&
parts.every((part) => part.type === "tool-result" && part.provider_executed);
const isMetadataOnlyMessage = (
parts: readonly TypesGen.ChatMessagePart[],
): boolean =>
parts.length > 0 &&
parts.every((part) => part.type === "context-file" || part.type === "skill");
export const deriveMessageDisplayState = ({
message,
parsed,
hideActions,
}: {
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
hideActions: boolean;
}): MessageDisplayState => {
const isUser = message.role === "user";
const userInlineContent = isUser
? parsed.blocks.filter(isUserInlineRenderBlock)
: [];
const userFileBlocks = isUser
? parsed.blocks.filter(isUserFileRenderBlock)
: [];
const hasUserMessageBody =
userInlineContent.length > 0 || Boolean(parsed.markdown.trim());
const hasFileBlocks = userFileBlocks.length > 0;
const hasCopyableContent = Boolean(parsed.markdown.trim());
const needsAssistantBottomSpacer =
!hideActions &&
!isUser &&
!hasCopyableContent &&
(Boolean(parsed.reasoning) || parsed.sources.length > 0);
const hasToolResultsOnly =
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
parsed.markdown === "" &&
parsed.reasoning === "";
const parts = message.content ?? [];
return {
shouldHide:
hasToolResultsOnly ||
isProviderToolResultOnlyMessage(parts) ||
isMetadataOnlyMessage(parts),
userInlineContent,
userFileBlocks,
hasUserMessageBody,
hasFileBlocks,
hasCopyableContent,
needsAssistantBottomSpacer,
};
};
@@ -1,5 +1,6 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { type FC, type FormEvent, useId, useState } from "react";
import { useMutation } from "react-query";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
@@ -51,29 +52,26 @@ const filterQuestionOptions = (question: AskUserQuestion): AskUserQuestion => ({
),
});
const formatAnswer = (answer: QuestionAnswer): string =>
answer.kind === "other"
? `Other: ${answer.text.trim()}`
: answer.label || `Option ${answer.optionIndex + 1}`;
const cloneAnswer = (
answer: QuestionAnswer | undefined,
const getDefaultAnswer = (
question: AskUserQuestion,
): QuestionAnswer | undefined => {
if (!answer) {
const firstOption = question.options[0];
if (!firstOption) {
return undefined;
}
if (answer.kind === "other") {
return { kind: "other", text: answer.text };
}
return {
kind: "option",
label: answer.label,
optionIndex: answer.optionIndex,
label: firstOption.label || "Option 1",
optionIndex: 0,
};
};
const formatAnswer = (answer: QuestionAnswer): string =>
answer.kind === "other"
? `Other: ${answer.text.trim()}`
: answer.label || `Option ${answer.optionIndex + 1}`;
const isAnswerValid = (
answer: QuestionAnswer | undefined,
): answer is QuestionAnswer => {
@@ -117,6 +115,243 @@ const formatOutgoingMessage = (
.join("\n");
};
const getSubmissionErrorMessage = (error: unknown): string | undefined => {
if (!error) {
return undefined;
}
if (error instanceof Error) {
return error.message;
}
return "Failed to submit your answer.";
};
type SelectableAnswerOptionProps = {
id: string;
value: string;
label: string;
description: string;
isInteractive: boolean;
isSubmitting: boolean;
};
const SelectableAnswerOption: FC<SelectableAnswerOptionProps> = ({
id,
value,
label,
description,
isInteractive,
isSubmitting,
}) => {
const isEnabled = isInteractive && !isSubmitting;
return (
<label
htmlFor={id}
className={cn(
"grid gap-x-3 gap-y-0.5 py-1.5",
isEnabled ? "cursor-pointer" : "cursor-default",
)}
style={{ gridTemplateColumns: "auto 1fr" }}
>
<RadioGroupItem
className="self-center"
disabled={!isEnabled}
id={id}
value={value}
/>
<span className="text-sm font-medium text-content-primary">{label}</span>
<p className="col-start-2 m-0 whitespace-pre-wrap text-sm text-content-secondary">
{description}
</p>
</label>
);
};
type QuestionOptionProps = {
questionIdBase: string;
option: AskUserQuestion["options"][number];
optionIndex: number;
isInteractive: boolean;
isSubmitting: boolean;
};
const QuestionOption: FC<QuestionOptionProps> = ({
questionIdBase,
option,
optionIndex,
isInteractive,
isSubmitting,
}) => {
return (
<SelectableAnswerOption
id={`${questionIdBase}-option-${optionIndex}`}
value={`option-${optionIndex}`}
label={option.label || `Option ${optionIndex + 1}`}
description={option.description || "No description provided."}
isInteractive={isInteractive}
isSubmitting={isSubmitting}
/>
);
};
type OtherQuestionOptionProps = {
questionHeader: string;
questionIdBase: string;
optionIndex: number;
answer: QuestionAnswer | undefined;
isInteractive: boolean;
isSubmitting: boolean;
onTextChange: (text: string) => void;
};
const OtherQuestionOption: FC<OtherQuestionOptionProps> = ({
questionHeader,
questionIdBase,
optionIndex,
answer,
isInteractive,
isSubmitting,
onTextChange,
}) => {
const isOtherSelected = answer?.kind === "other";
return (
<div className="space-y-2">
<SelectableAnswerOption
id={`${questionIdBase}-option-${optionIndex}`}
value={OTHER_OPTION_VALUE}
label="Other"
description="Share a different answer."
isInteractive={isInteractive}
isSubmitting={isSubmitting}
/>
{isOtherSelected && (
<div className="pl-7">
<Input
autoFocus={isInteractive}
aria-label={`Other response for ${questionHeader}`}
disabled={!isInteractive || isSubmitting}
placeholder="Describe another answer"
value={answer.text}
onChange={(event) => {
onTextChange(event.currentTarget.value);
}}
/>
</div>
)}
</div>
);
};
type QuestionStepProps = {
question: AskUserQuestion;
questionIndex: number;
questionCount: number;
idPrefix: string;
answer: QuestionAnswer | undefined;
isInteractive: boolean;
isSubmitting: boolean;
onOptionChange: (value: string) => void;
onOtherTextChange: (text: string) => void;
};
const QuestionStep: FC<QuestionStepProps> = ({
question,
questionIndex,
questionCount,
idPrefix,
answer,
isInteractive,
isSubmitting,
onOptionChange,
onOtherTextChange,
}) => {
const questionHeader = getQuestionHeader(question, questionIndex);
const questionText = getQuestionText(question);
const questionIdBase = `${idPrefix}-question-${questionIndex}`;
const questionHeaderId = `${questionIdBase}-header`;
const questionTextId = `${questionIdBase}-text`;
const showProgress = isInteractive && questionCount > 1;
return (
<div className="space-y-3">
{showProgress && (
<p className="text-xs font-medium text-content-secondary">
Question {questionIndex + 1} of {questionCount}
</p>
)}
<div className="space-y-1.5">
<p
id={questionHeaderId}
className="text-xs font-medium text-content-secondary"
>
{questionHeader}
</p>
<p
id={questionTextId}
className="whitespace-pre-wrap text-sm text-content-primary"
>
{questionText}
</p>
</div>
<div className="rounded-md border border-solid border-border-default px-3 py-1">
<RadioGroup
aria-labelledby={`${questionHeaderId} ${questionTextId}`}
className="space-y-1"
name={`${questionIdBase}-options`}
value={getSelectedValue(answer)}
onValueChange={onOptionChange}
>
{question.options.map((option, optionIndex) => {
return (
<QuestionOption
key={`${option.label}-${option.description}-${optionIndex}`}
questionIdBase={questionIdBase}
option={option}
optionIndex={optionIndex}
isInteractive={isInteractive}
isSubmitting={isSubmitting}
/>
);
})}
<OtherQuestionOption
questionHeader={questionHeader}
questionIdBase={questionIdBase}
optionIndex={question.options.length}
answer={answer}
isInteractive={isInteractive}
isSubmitting={isSubmitting}
onTextChange={onOtherTextChange}
/>
</RadioGroup>
</div>
</div>
);
};
type AnsweredQuestionTextProps = {
question: AskUserQuestion;
questionIndex: number;
idPrefix: string;
};
const AnsweredQuestionText: FC<AnsweredQuestionTextProps> = ({
question,
questionIndex,
idPrefix,
}) => {
return (
<p
id={`${idPrefix}-question-${questionIndex}-text`}
className="whitespace-pre-wrap text-sm text-content-primary"
>
{getQuestionText(question)}
</p>
);
};
export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
questions,
status,
@@ -130,25 +365,24 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
const idPrefix = useId();
const filteredQuestions = questions.map(filterQuestionOptions);
const [answers, setAnswers] = useState<Array<QuestionAnswer | undefined>>(
() =>
filteredQuestions.map((question) => {
const firstOption = question.options[0];
if (!firstOption) {
return undefined;
}
return {
kind: "option" as const,
label: firstOption.label || "Option 1",
optionIndex: 0,
};
}),
() => filteredQuestions.map(getDefaultAnswer),
);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | undefined>();
const [submittedResponseText, setSubmittedResponseText] = useState<
string | null
>(null);
const submitAnswerMutation = useMutation({
mutationFn: async (message: string) => {
if (!onSubmitAnswer) {
return;
}
await onSubmitAnswer(message);
},
onSuccess: (_data, message) => {
setSubmittedResponseText(message);
},
});
const isRunning = status === "running";
const displayedSubmittedResponseText =
previousResponseText ?? submittedResponseText;
@@ -166,10 +400,30 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
isLatestAskUserQuestion &&
!hasSubmittedResponse &&
Boolean(onSubmitAnswer);
const isSubmitting = submitAnswerMutation.isPending;
const submitError = getSubmissionErrorMessage(submitAnswerMutation.error);
const canAdvanceToNextQuestion = isAnswerValid(currentAnswer);
const canSubmitAllAnswers = filteredQuestions.every((_, questionIndex) =>
isAnswerValid(answers[questionIndex]),
);
const isWizard = filteredQuestions.length > 1;
const isFinalQuestion = activeQuestionIndex >= filteredQuestions.length - 1;
const visibleQuestions =
isInteractive && isWizard
? [
{
question: filteredQuestions[activeQuestionIndex],
questionIndex: activeQuestionIndex,
},
]
: filteredQuestions.map((question, questionIndex) => ({
question,
questionIndex,
}));
const resetSubmitState = () => {
submitAnswerMutation.reset();
};
const setAnswerAtIndex = (
questionIndex: number,
@@ -180,7 +434,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
nextAnswers[questionIndex] = nextAnswer;
return nextAnswers;
});
setSubmitError(undefined);
resetSubmitState();
};
const handleOptionChange = (
@@ -216,7 +470,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
setCurrentQuestionIndex((currentIndex) => {
return Math.max(currentIndex - 1, 0);
});
setSubmitError(undefined);
resetSubmitState();
};
const handleNext = () => {
@@ -227,16 +481,16 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
setCurrentQuestionIndex((currentIndex) => {
return Math.min(currentIndex + 1, filteredQuestions.length - 1);
});
setSubmitError(undefined);
resetSubmitState();
};
const handleSubmit = async () => {
const handleSubmit = () => {
if (!onSubmitAnswer || !isInteractive || !canSubmitAllAnswers) {
return;
}
const finalizedAnswers = filteredQuestions.map((_, questionIndex) => {
return cloneAnswer(answers[questionIndex]);
return answers[questionIndex];
});
if (!finalizedAnswers.every(isAnswerValid)) {
return;
@@ -246,22 +500,8 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
filteredQuestions,
finalizedAnswers,
);
setIsSubmitting(true);
setSubmitError(undefined);
try {
await onSubmitAnswer(outgoingMessage);
} catch (error) {
setSubmitError(
error instanceof Error
? error.message
: "Failed to submit your answer.",
);
setIsSubmitting(false);
return;
}
setSubmittedResponseText(outgoingMessage);
setIsSubmitting(false);
resetSubmitState();
submitAnswerMutation.mutate(outgoingMessage);
};
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
@@ -270,15 +510,12 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
return;
}
if (
filteredQuestions.length > 1 &&
activeQuestionIndex < filteredQuestions.length - 1
) {
if (isWizard && !isFinalQuestion) {
handleNext();
return;
}
void handleSubmit();
handleSubmit();
};
if (isError) {
@@ -324,161 +561,42 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
);
}
const visibleQuestions =
isInteractive && filteredQuestions.length > 1
? [
{
question: filteredQuestions[activeQuestionIndex],
questionIndex: activeQuestionIndex,
},
]
: filteredQuestions.map((question, questionIndex) => ({
question,
questionIndex,
}));
const content = (
<>
<div className="space-y-5">
{visibleQuestions.map(({ question, questionIndex }) => {
const questionHeader = getQuestionHeader(question, questionIndex);
const questionText = getQuestionText(question);
const questionIdBase = `${idPrefix}-question-${questionIndex}`;
const questionHeaderId = `${questionIdBase}-header`;
const questionTextId = `${questionIdBase}-text`;
const answer = answers[questionIndex];
const isOtherSelected = answer?.kind === "other";
const optionCount = question.options.length;
const showProgress = isInteractive && filteredQuestions.length > 1;
const questionKey = `${question.header}-${question.question}-${questionIndex}`;
if (showAnsweredState) {
return (
<p
key={`${question.header}-${question.question}-${questionIndex}`}
id={questionTextId}
className="whitespace-pre-wrap text-sm text-content-primary"
>
{questionText}
</p>
<AnsweredQuestionText
key={questionKey}
question={question}
questionIndex={questionIndex}
idPrefix={idPrefix}
/>
);
}
return (
<div
key={`${question.header}-${question.question}-${questionIndex}`}
className="space-y-3"
>
{showProgress && (
<p className="text-xs font-medium text-content-secondary">
Question {questionIndex + 1} of {filteredQuestions.length}
</p>
)}
<div className="space-y-1.5">
<p
id={questionHeaderId}
className="text-xs font-medium text-content-secondary"
>
{questionHeader}
</p>
<p
id={questionTextId}
className="whitespace-pre-wrap text-sm text-content-primary"
>
{questionText}
</p>
</div>
<div className="rounded-md border border-solid border-border-default px-3 py-1">
<RadioGroup
aria-labelledby={`${questionHeaderId} ${questionTextId}`}
className="space-y-1"
name={`${questionIdBase}-options`}
value={getSelectedValue(answer)}
onValueChange={(value) => {
handleOptionChange(questionIndex, question, value);
}}
>
{question.options.map((option, optionIndex) => {
const optionId = `${questionIdBase}-option-${optionIndex}`;
return (
<label
key={`${option.label}-${option.description}-${optionIndex}`}
htmlFor={optionId}
className={cn(
"grid gap-x-3 gap-y-0.5 py-1.5",
isInteractive && !isSubmitting
? "cursor-pointer"
: "cursor-default",
)}
style={{ gridTemplateColumns: "auto 1fr" }}
>
<RadioGroupItem
className="self-center"
disabled={!isInteractive || isSubmitting}
id={optionId}
value={`option-${optionIndex}`}
/>
<span className="text-sm font-medium text-content-primary">
{option.label || `Option ${optionIndex + 1}`}
</span>
<p className="col-start-2 m-0 whitespace-pre-wrap text-sm text-content-secondary">
{option.description || "No description provided."}
</p>
</label>
);
})}
{(() => {
const otherOptionId = `${questionIdBase}-option-${optionCount}`;
return (
<div className="space-y-2">
<label
htmlFor={otherOptionId}
className={cn(
"grid gap-x-3 gap-y-0.5 py-1.5",
isInteractive && !isSubmitting
? "cursor-pointer"
: "cursor-default",
)}
style={{ gridTemplateColumns: "auto 1fr" }}
>
<RadioGroupItem
className="self-center"
disabled={!isInteractive || isSubmitting}
id={otherOptionId}
value={OTHER_OPTION_VALUE}
/>
<span className="text-sm font-medium text-content-primary">
Other
</span>
<p className="col-start-2 m-0 whitespace-pre-wrap text-sm text-content-secondary">
Share a different answer.
</p>
</label>
{isOtherSelected && (
<div className="pl-7">
<Input
autoFocus={isInteractive}
aria-label={`Other response for ${questionHeader}`}
disabled={!isInteractive || isSubmitting}
placeholder="Describe another answer"
value={
answer?.kind === "other" ? answer.text : ""
}
onChange={(event) => {
setAnswerAtIndex(questionIndex, {
kind: "other",
text: event.currentTarget.value,
});
}}
/>
</div>
)}
</div>
);
})()}
</RadioGroup>
</div>
</div>
<QuestionStep
key={questionKey}
question={question}
questionIndex={questionIndex}
questionCount={filteredQuestions.length}
idPrefix={idPrefix}
answer={answers[questionIndex]}
isInteractive={isInteractive}
isSubmitting={isSubmitting}
onOptionChange={(value) => {
handleOptionChange(questionIndex, question, value);
}}
onOtherTextChange={(text) => {
setAnswerAtIndex(questionIndex, {
kind: "other",
text,
});
}}
/>
);
})}
</div>
@@ -506,7 +624,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
{isInteractive && (
<div className="mt-4 flex items-center gap-2">
{filteredQuestions.length > 1 && (
{isWizard && (
<Button
type="button"
size="sm"
@@ -517,8 +635,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
Back
</Button>
)}
{filteredQuestions.length > 1 &&
activeQuestionIndex < filteredQuestions.length - 1 ? (
{isWizard && !isFinalQuestion ? (
<Button
type="submit"
size="sm"
@@ -1,7 +1,6 @@
import { LoaderIcon, PlayIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useQuery } from "react-query";
import { useMutation, useQuery } from "react-query";
import { API } from "#/api/api";
import { Button } from "#/components/Button/Button";
import { CopyButton } from "#/components/CopyButton/CopyButton";
@@ -59,7 +58,12 @@ export const ProposePlanTool: React.FC<{
const effectiveError = isError || Boolean(fetchError);
const effectiveErrorMessage = errorMessage || fetchError;
const hasDisplayContent = displayContent.trim().length > 0;
const [isSubmitting, setIsSubmitting] = useState(false);
const implementPlanMutation = useMutation({
mutationFn: async () => {
if (!onImplementPlan) return;
await onImplementPlan();
},
});
const canImplementPlan =
status === "completed" &&
!effectiveError &&
@@ -67,19 +71,6 @@ export const ProposePlanTool: React.FC<{
hasDisplayContent &&
Boolean(onImplementPlan);
const handleImplementPlanClick = async () => {
if (!onImplementPlan || isSubmitting) {
return;
}
setIsSubmitting(true);
try {
await onImplementPlan();
setIsSubmitting(false);
} catch {
setIsSubmitting(false);
}
};
return (
<div className="w-full">
<div className="flex items-center gap-1.5 py-0.5">
@@ -116,17 +107,21 @@ export const ProposePlanTool: React.FC<{
variant="subtle"
size="sm"
onClick={() => {
void handleImplementPlanClick();
implementPlanMutation.mutate();
}}
disabled={isSubmitting}
disabled={
!canImplementPlan || implementPlanMutation.isPending
}
aria-label="Implement plan"
>
{isSubmitting ? (
{implementPlanMutation.isPending ? (
<LoaderIcon className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayIcon />
)}
{isSubmitting ? "Implementing..." : "Implement"}
{implementPlanMutation.isPending
? "Implementing..."
: "Implement"}
</Button>
</TooltipTrigger>
<TooltipContent>Implement plan</TooltipContent>
@@ -59,7 +59,6 @@ export const PlanModeInstructionsSettings: FC<
const planModeInvisibleCharCount = countInvisibleCharacters(
form.values.plan_mode_instructions,
);
const planModeInvisibleCharWarning = `This text contains ${planModeInvisibleCharCount} invisible Unicode ${planModeInvisibleCharCount !== 1 ? "characters" : "character"} that could hide content. These will be stripped on save.`;
const isPlanModeInstructionsDisabled =
isAnyPromptSaving || !hasLoadedPlanModeInstructions;
@@ -94,7 +93,11 @@ export const PlanModeInstructionsSettings: FC<
/>
{planModeInvisibleCharCount > 0 && (
<Alert severity="warning">
<AlertDescription>{planModeInvisibleCharWarning}</AlertDescription>
<AlertDescription>
This text contains {planModeInvisibleCharCount} invisible Unicode{" "}
{planModeInvisibleCharCount !== 1 ? "characters" : "character"} that
could hide content. These will be stripped on save.
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">