mirror of
https://github.com/coder/coder.git
synced 2026-09-23 22:20:22 +08:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user