mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend/copilot): migrate ChatInput to ai-sdk prompt-input component (#12207)
## Summary - **Migrate ChatInput** to composable `PromptInput*` sub-components from AI SDK Elements, replacing the custom implementation with a boxy, Claude-style input layout (textarea + footer with tools and submit) - **Eliminate JS-based DOM height manipulation** (60+ lines removed from `useChatInput.ts`) in favor of CSS-driven auto-resize via `min-h`/`max-h`, fixing input sizing jumps (SECRT-2040) - **Change stop button color** from red to black (`bg-zinc-800`) per SECRT-2038, while keeping mic recording button red - **Add new UI primitives**: `InputGroup`, `Spinner`, `Textarea`, and `prompt-input` composition layer ### New files - `src/components/ai-elements/prompt-input.tsx` — Composable prompt input sub-components (PromptInput, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit) - `src/components/ui/input-group.tsx` — Layout primitive with flex-col support, rounded-xlarge styling - `src/components/ui/spinner.tsx` — Loading spinner using Phosphor CircleNotch - `src/components/ui/textarea.tsx` — Base shadcn Textarea component ### Modified files - `ChatInput.tsx` — Rewritten to compose PromptInput* sub-components with InputGroup - `useChatInput.ts` — Simplified: removed maxRows, hasMultipleLines, handleKeyDown, all DOM style manipulation - `useVoiceRecording.ts` — Removed `baseHandleKeyDown` dependency; PromptInputTextarea handles Enter→submit natively ## Resolves - SECRT-2042: Migrate copilot chat input to ai-sdk prompt-input component - SECRT-2038: Change stop button color from red to black ## Test plan - [ ] Type a message and send it — verify it submits and clears the input - [ ] Multi-line input grows smoothly without sizing jumps - [ ] Press Enter to send, Shift+Enter for new line - [ ] Voice recording: press space on empty input to start, space again to stop - [ ] Mic button stays red while recording; stop-generating button is black - [ ] Input has boxy rectangular shape with rounded-xlarge corners - [ ] Streaming: stop button appears during generation, clicking it stops the stream - [ ] EmptySession layout renders correctly with the new input - [ ] Input is disabled during transcription with "Transcribing..." placeholder 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
CircleNotchIcon,
|
||||
MicrophoneIcon,
|
||||
StopIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ChangeEvent, useCallback } from "react";
|
||||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { InputGroup } from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleNotchIcon, MicrophoneIcon } from "@phosphor-icons/react";
|
||||
import { ChangeEvent } from "react";
|
||||
import { RecordingIndicator } from "./components/RecordingIndicator";
|
||||
import { useChatInput } from "./useChatInput";
|
||||
import { useVoiceRecording } from "./useVoiceRecording";
|
||||
@@ -33,14 +36,11 @@ export function ChatInput({
|
||||
const {
|
||||
value,
|
||||
setValue,
|
||||
handleKeyDown: baseHandleKeyDown,
|
||||
handleSubmit,
|
||||
handleChange: baseHandleChange,
|
||||
hasMultipleLines,
|
||||
} = useChatInput({
|
||||
onSend,
|
||||
disabled: disabled || isStreaming,
|
||||
maxRows: 4,
|
||||
inputId,
|
||||
});
|
||||
|
||||
@@ -58,60 +58,35 @@ export function ChatInput({
|
||||
disabled: disabled || isStreaming,
|
||||
isStreaming,
|
||||
value,
|
||||
baseHandleKeyDown,
|
||||
inputId,
|
||||
});
|
||||
|
||||
// Block text changes when recording
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (isRecording) return;
|
||||
baseHandleChange(e);
|
||||
},
|
||||
[isRecording, baseHandleChange],
|
||||
);
|
||||
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
|
||||
if (isRecording) return;
|
||||
baseHandleChange(e);
|
||||
}
|
||||
|
||||
const canSend =
|
||||
!disabled && !!value.trim() && !isRecording && !isTranscribing;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
|
||||
<div className="relative">
|
||||
<div
|
||||
id={`${inputId}-wrapper`}
|
||||
className={cn(
|
||||
"relative overflow-hidden border bg-white shadow-sm",
|
||||
"focus-within:ring-1",
|
||||
isRecording
|
||||
? "border-red-400 focus-within:border-red-400 focus-within:ring-red-400"
|
||||
: "border-neutral-200 focus-within:border-zinc-400 focus-within:ring-zinc-400",
|
||||
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
||||
)}
|
||||
>
|
||||
{!value && !isRecording && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 top-0.5 flex items-center justify-start pl-14 text-[1rem] text-zinc-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isTranscribing ? "Transcribing..." : placeholder}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"overflow-hidden has-[[data-slot=input-group-control]:focus-visible]:border-neutral-200 has-[[data-slot=input-group-control]:focus-visible]:ring-0",
|
||||
isRecording &&
|
||||
"border-red-400 ring-1 ring-red-400 has-[[data-slot=input-group-control]:focus-visible]:border-red-400 has-[[data-slot=input-group-control]:focus-visible]:ring-red-400",
|
||||
)}
|
||||
>
|
||||
<PromptInputBody className="relative block w-full">
|
||||
<PromptInputTextarea
|
||||
id={inputId}
|
||||
aria-label="Chat message input"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isInputDisabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full resize-none overflow-y-auto border-0 bg-transparent text-[1rem] leading-6 text-black",
|
||||
"placeholder:text-zinc-400",
|
||||
"focus:outline-none focus:ring-0",
|
||||
"disabled:text-zinc-500",
|
||||
hasMultipleLines
|
||||
? "pb-6 pl-4 pr-4 pt-2"
|
||||
: showMicButton
|
||||
? "pb-4 pl-14 pr-14 pt-4"
|
||||
: "pb-4 pl-4 pr-14 pt-4",
|
||||
)}
|
||||
placeholder={isTranscribing ? "Transcribing..." : placeholder}
|
||||
/>
|
||||
{isRecording && !value && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -121,67 +96,43 @@ export function ChatInput({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span id="chat-input-hint" className="sr-only">
|
||||
</PromptInputBody>
|
||||
|
||||
<span id={`${inputId}-hint`} className="sr-only">
|
||||
Press Enter to send, Shift+Enter for new line, Space to record voice
|
||||
</span>
|
||||
|
||||
{showMicButton && (
|
||||
<div className="absolute bottom-[7px] left-2 flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||
onClick={toggleRecording}
|
||||
disabled={disabled || isTranscribing || isStreaming}
|
||||
className={cn(
|
||||
isRecording
|
||||
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
||||
: isTranscribing
|
||||
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
||||
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
||||
isStreaming && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<CircleNotchIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MicrophoneIcon className="h-4 w-4" weight="bold" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
{showMicButton && (
|
||||
<PromptInputButton
|
||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||
onClick={toggleRecording}
|
||||
disabled={disabled || isTranscribing || isStreaming}
|
||||
className={cn(
|
||||
"size-[2.625rem] rounded-[96px] border border-zinc-300 bg-transparent text-black hover:border-zinc-600 hover:bg-zinc-100",
|
||||
isRecording &&
|
||||
"animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600",
|
||||
isTranscribing && "bg-zinc-100 text-zinc-400",
|
||||
isStreaming && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<CircleNotchIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MicrophoneIcon className="h-4 w-4" weight="bold" />
|
||||
)}
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
|
||||
<div className="absolute bottom-[7px] right-2 flex items-center gap-1">
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Stop generating"
|
||||
onClick={onStop}
|
||||
className="border-red-600 bg-red-600 text-white hover:border-red-800 hover:bg-red-800"
|
||||
>
|
||||
<StopIcon className="h-4 w-4" weight="bold" />
|
||||
</Button>
|
||||
<PromptInputSubmit status="streaming" onStop={onStop} />
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900",
|
||||
(disabled || !value.trim() || isRecording) && "opacity-20",
|
||||
)}
|
||||
disabled={disabled || !value.trim() || isRecording}
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4" weight="bold" />
|
||||
</Button>
|
||||
<PromptInputSubmit disabled={!canSend} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</InputGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
|
||||
interface Args {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
maxRows?: number;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function useChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
maxRows = 5,
|
||||
inputId = "chat-input",
|
||||
}: Args) {
|
||||
const [value, setValue] = useState("");
|
||||
const [hasMultipleLines, setHasMultipleLines] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
useEffect(
|
||||
@@ -40,67 +31,6 @@ export function useChatInput({
|
||||
[disabled, inputId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
const wrapper = document.getElementById(
|
||||
`${inputId}-wrapper`,
|
||||
) as HTMLDivElement;
|
||||
if (!textarea || !wrapper) return;
|
||||
|
||||
const isEmpty = !value.trim();
|
||||
const lines = value.split("\n").length;
|
||||
const hasExplicitNewlines = lines > 1;
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const lineHeight = parseInt(computedStyle.lineHeight, 10);
|
||||
const paddingTop = parseInt(computedStyle.paddingTop, 10);
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom, 10);
|
||||
|
||||
const singleLinePadding = paddingTop + paddingBottom;
|
||||
|
||||
textarea.style.height = "auto";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
|
||||
const singleLineHeight = lineHeight + singleLinePadding;
|
||||
const isMultiLine =
|
||||
hasExplicitNewlines || scrollHeight > singleLineHeight + 2;
|
||||
setHasMultipleLines(isMultiLine);
|
||||
|
||||
if (isEmpty) {
|
||||
wrapper.style.height = `${singleLineHeight}px`;
|
||||
wrapper.style.maxHeight = "";
|
||||
textarea.style.height = `${singleLineHeight}px`;
|
||||
textarea.style.maxHeight = "";
|
||||
textarea.style.overflowY = "hidden";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMultiLine) {
|
||||
const wrapperMaxHeight = 196;
|
||||
const currentMultilinePadding = paddingTop + paddingBottom;
|
||||
const contentMaxHeight = wrapperMaxHeight - currentMultilinePadding;
|
||||
const minMultiLineHeight = lineHeight * 2 + currentMultilinePadding;
|
||||
const contentHeight = scrollHeight;
|
||||
const targetWrapperHeight = Math.min(
|
||||
Math.max(contentHeight + currentMultilinePadding, minMultiLineHeight),
|
||||
wrapperMaxHeight,
|
||||
);
|
||||
|
||||
wrapper.style.height = `${targetWrapperHeight}px`;
|
||||
wrapper.style.maxHeight = `${wrapperMaxHeight}px`;
|
||||
textarea.style.height = `${contentHeight}px`;
|
||||
textarea.style.maxHeight = `${contentMaxHeight}px`;
|
||||
textarea.style.overflowY =
|
||||
contentHeight > contentMaxHeight ? "auto" : "hidden";
|
||||
} else {
|
||||
wrapper.style.height = `${singleLineHeight}px`;
|
||||
wrapper.style.maxHeight = "";
|
||||
textarea.style.height = `${singleLineHeight}px`;
|
||||
textarea.style.maxHeight = "";
|
||||
textarea.style.overflowY = "hidden";
|
||||
}
|
||||
}, [value, maxRows, inputId]);
|
||||
|
||||
async function handleSend() {
|
||||
if (disabled || isSending || !value.trim()) return;
|
||||
|
||||
@@ -108,30 +38,11 @@ export function useChatInput({
|
||||
try {
|
||||
await onSend(value.trim());
|
||||
setValue("");
|
||||
setHasMultipleLines(false);
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
const wrapper = document.getElementById(
|
||||
`${inputId}-wrapper`,
|
||||
) as HTMLDivElement;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
if (wrapper) {
|
||||
wrapper.style.height = "";
|
||||
wrapper.style.maxHeight = "";
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
@@ -144,11 +55,9 @@ export function useChatInput({
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
handleKeyDown,
|
||||
handleSend,
|
||||
handleSubmit,
|
||||
handleChange,
|
||||
hasMultipleLines,
|
||||
isSending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface Args {
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
value: string;
|
||||
baseHandleKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
@@ -23,7 +22,6 @@ export function useVoiceRecording({
|
||||
disabled = false,
|
||||
isStreaming = false,
|
||||
value,
|
||||
baseHandleKeyDown,
|
||||
inputId,
|
||||
}: Args) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
@@ -237,9 +235,9 @@ export function useVoiceRecording({
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
baseHandleKeyDown(event);
|
||||
// Let PromptInputTextarea handle remaining keys (Enter → submit, etc.)
|
||||
},
|
||||
[value, isTranscribing, stopRecording, startRecording, baseHandleKeyDown],
|
||||
[value, isTranscribing, stopRecording, startRecording],
|
||||
);
|
||||
|
||||
const showMicButton = isSupported;
|
||||
|
||||
@@ -16,6 +16,10 @@ import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { ClarificationQuestionsCard } from "./components/ClarificationQuestionsCard";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
||||
import {
|
||||
buildClarificationAnswersMessage,
|
||||
normalizeClarifyingQuestions,
|
||||
} from "../clarifying-questions";
|
||||
import {
|
||||
AccordionIcon,
|
||||
formatMaybeJson,
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
isSuggestedGoalOutput,
|
||||
ToolIcon,
|
||||
truncateText,
|
||||
normalizeClarifyingQuestions,
|
||||
type CreateAgentToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
@@ -110,16 +113,7 @@ export function CreateAgentTool({ part }: Props) {
|
||||
? (output.questions ?? [])
|
||||
: [];
|
||||
|
||||
const contextMessage = questions
|
||||
.map((q) => {
|
||||
const answer = answers[q.keyword] || "";
|
||||
return `> ${q.question}\n\n${answer}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
onSend(
|
||||
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
|
||||
);
|
||||
onSend(buildClarificationAnswersMessage(answers, questions, "create"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatTeardropDotsIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClarifyingQuestion } from "../helpers";
|
||||
import type { ClarifyingQuestion } from "../../clarifying-questions";
|
||||
|
||||
interface Props {
|
||||
questions: ClarifyingQuestion[];
|
||||
@@ -149,20 +149,20 @@ export function ClarificationQuestionsCard({
|
||||
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, index) => {
|
||||
const isAnswered = !!answers[q.keyword]?.trim();
|
||||
const hasAnswer = !!answers[q.keyword]?.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${q.keyword}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border border-dotted p-3",
|
||||
isAnswered
|
||||
hasAnswer
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-100 bg-slate-50/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
{isAnswered ? (
|
||||
{hasAnswer ? (
|
||||
<CheckCircleIcon
|
||||
size={20}
|
||||
className="mt-0.5 text-green-500"
|
||||
|
||||
@@ -157,41 +157,3 @@ export function truncateText(text: string, maxChars: number): string {
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export function normalizeClarifyingQuestions(
|
||||
questions: Array<{ question: string; keyword: string; example?: unknown }>,
|
||||
): ClarifyingQuestion[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return questions.map((q, index) => {
|
||||
let keyword = q.keyword?.trim().toLowerCase() || "";
|
||||
if (!keyword) {
|
||||
keyword = `question-${index}`;
|
||||
}
|
||||
|
||||
let unique = keyword;
|
||||
let suffix = 1;
|
||||
while (seen.has(unique)) {
|
||||
unique = `${keyword}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
seen.add(unique);
|
||||
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: unique,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
buildClarificationAnswersMessage,
|
||||
normalizeClarifyingQuestions,
|
||||
} from "../clarifying-questions";
|
||||
import { ClarificationQuestionsCard } from "../CreateAgent/components/ClarificationQuestionsCard";
|
||||
import { normalizeClarifyingQuestions } from "../CreateAgent/helpers";
|
||||
import {
|
||||
AccordionIcon,
|
||||
formatMaybeJson,
|
||||
@@ -99,16 +102,7 @@ export function EditAgentTool({ part }: Props) {
|
||||
? (output.questions ?? [])
|
||||
: [];
|
||||
|
||||
const contextMessage = questions
|
||||
.map((q) => {
|
||||
const answer = answers[q.keyword] || "";
|
||||
return `> ${q.question}\n\n${answer}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
onSend(
|
||||
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with editing the agent.`,
|
||||
);
|
||||
onSend(buildClarificationAnswersMessage(answers, questions, "edit"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
|
||||
import { buildInputSchema, extractDefaults, isFormValid } from "./helpers";
|
||||
@@ -24,6 +24,14 @@ export function AgentDetailsCard({ output }: Props) {
|
||||
schema ? isFormValid(schema, defaults) : false,
|
||||
);
|
||||
|
||||
// Reset form state when the agent changes (e.g. during mid-conversation switches)
|
||||
useEffect(() => {
|
||||
const newSchema = buildInputSchema(output.agent.inputs);
|
||||
const newDefaults = newSchema ? extractDefaults(newSchema) : {};
|
||||
setInputValues(newDefaults);
|
||||
setValid(newSchema ? isFormValid(newSchema, newDefaults) : false);
|
||||
}, [output.agent.id]);
|
||||
|
||||
function handleChange(v: { formData?: Record<string, unknown> }) {
|
||||
const data = v.formData ?? {};
|
||||
setInputValues(data);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildInputSchema, extractDefaults, isFormValid } from "./helpers";
|
||||
|
||||
describe("buildInputSchema", () => {
|
||||
it("returns null for falsy input", () => {
|
||||
expect(buildInputSchema(null)).toBeNull();
|
||||
expect(buildInputSchema(undefined)).toBeNull();
|
||||
expect(buildInputSchema("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty properties object", () => {
|
||||
expect(buildInputSchema({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the schema when properties exist", () => {
|
||||
const schema = { name: { type: "string" as const } };
|
||||
expect(buildInputSchema(schema)).toBe(schema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDefaults", () => {
|
||||
it("returns an empty object when no properties exist", () => {
|
||||
expect(extractDefaults({})).toEqual({});
|
||||
expect(extractDefaults({ properties: null as never })).toEqual({});
|
||||
});
|
||||
|
||||
it("extracts default values from property definitions", () => {
|
||||
const schema: RJSFSchema = {
|
||||
properties: {
|
||||
name: { type: "string", default: "Alice" },
|
||||
age: { type: "number", default: 30 },
|
||||
},
|
||||
};
|
||||
expect(extractDefaults(schema)).toEqual({ name: "Alice", age: 30 });
|
||||
});
|
||||
|
||||
it("falls back to the first example when no default is defined", () => {
|
||||
const schema: RJSFSchema = {
|
||||
properties: {
|
||||
query: { type: "string", examples: ["hello", "world"] },
|
||||
},
|
||||
};
|
||||
expect(extractDefaults(schema)).toEqual({ query: "hello" });
|
||||
});
|
||||
|
||||
it("prefers default over examples", () => {
|
||||
const schema: RJSFSchema = {
|
||||
properties: {
|
||||
value: { type: "string", default: "def", examples: ["ex"] },
|
||||
},
|
||||
};
|
||||
expect(extractDefaults(schema)).toEqual({ value: "def" });
|
||||
});
|
||||
|
||||
it("skips properties without default or examples", () => {
|
||||
const schema: RJSFSchema = {
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
title: { type: "string", default: "Mr." },
|
||||
},
|
||||
};
|
||||
expect(extractDefaults(schema)).toEqual({ title: "Mr." });
|
||||
});
|
||||
|
||||
it("skips properties that are not objects", () => {
|
||||
const schema: RJSFSchema = {
|
||||
properties: {
|
||||
bad: true,
|
||||
alsobad: false,
|
||||
},
|
||||
};
|
||||
expect(extractDefaults(schema)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFormValid", () => {
|
||||
it("returns true for a valid form", () => {
|
||||
const schema: RJSFSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
};
|
||||
expect(isFormValid(schema, { name: "Alice" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when required fields are missing", () => {
|
||||
const schema: RJSFSchema = {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
};
|
||||
expect(isFormValid(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty schema with empty data", () => {
|
||||
const schema: RJSFSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
expect(isFormValid(schema, {})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClarificationAnswersMessage,
|
||||
normalizeClarifyingQuestions,
|
||||
} from "./clarifying-questions";
|
||||
|
||||
describe("normalizeClarifyingQuestions", () => {
|
||||
it("returns normalized questions with trimmed lowercase keywords", () => {
|
||||
const result = normalizeClarifyingQuestions([
|
||||
{ question: "What is your goal?", keyword: " Goal ", example: "test" },
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ question: "What is your goal?", keyword: "goal", example: "test" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates keywords by appending a numeric suffix", () => {
|
||||
const result = normalizeClarifyingQuestions([
|
||||
{ question: "Q1", keyword: "topic" },
|
||||
{ question: "Q2", keyword: "topic" },
|
||||
{ question: "Q3", keyword: "topic" },
|
||||
]);
|
||||
expect(result.map((q) => q.keyword)).toEqual([
|
||||
"topic",
|
||||
"topic-1",
|
||||
"topic-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to question-{index} when keyword is empty", () => {
|
||||
const result = normalizeClarifyingQuestions([
|
||||
{ question: "First?", keyword: "" },
|
||||
{ question: "Second?", keyword: " " },
|
||||
]);
|
||||
expect(result[0].keyword).toBe("question-0");
|
||||
expect(result[1].keyword).toBe("question-1");
|
||||
});
|
||||
|
||||
it("coerces non-string examples to undefined", () => {
|
||||
const result = normalizeClarifyingQuestions([
|
||||
{ question: "Q1", keyword: "k1", example: 42 },
|
||||
{ question: "Q2", keyword: "k2", example: null },
|
||||
{ question: "Q3", keyword: "k3", example: { nested: true } },
|
||||
]);
|
||||
expect(result[0].example).toBeUndefined();
|
||||
expect(result[1].example).toBeUndefined();
|
||||
expect(result[2].example).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims string examples and omits empty ones", () => {
|
||||
const result = normalizeClarifyingQuestions([
|
||||
{ question: "Q1", keyword: "k1", example: " valid " },
|
||||
{ question: "Q2", keyword: "k2", example: " " },
|
||||
]);
|
||||
expect(result[0].example).toBe("valid");
|
||||
expect(result[1].example).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(normalizeClarifyingQuestions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClarificationAnswersMessage", () => {
|
||||
it("formats answers with create mode", () => {
|
||||
const result = buildClarificationAnswersMessage(
|
||||
{ goal: "automate tasks" },
|
||||
[{ question: "What is your goal?", keyword: "goal" }],
|
||||
"create",
|
||||
);
|
||||
expect(result).toContain("> What is your goal?");
|
||||
expect(result).toContain("automate tasks");
|
||||
expect(result).toContain("Please proceed with creating the agent.");
|
||||
});
|
||||
|
||||
it("formats answers with edit mode", () => {
|
||||
const result = buildClarificationAnswersMessage(
|
||||
{ goal: "fix bugs" },
|
||||
[{ question: "What should change?", keyword: "goal" }],
|
||||
"edit",
|
||||
);
|
||||
expect(result).toContain("Please proceed with editing the agent.");
|
||||
});
|
||||
|
||||
it("uses empty string for missing answers", () => {
|
||||
const result = buildClarificationAnswersMessage(
|
||||
{},
|
||||
[{ question: "Q?", keyword: "missing" }],
|
||||
"create",
|
||||
);
|
||||
expect(result).toContain("> Q?\n\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export function normalizeClarifyingQuestions(
|
||||
questions: Array<{ question: string; keyword: string; example?: unknown }>,
|
||||
): ClarifyingQuestion[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return questions.map((q, index) => {
|
||||
let keyword = q.keyword?.trim().toLowerCase() || "";
|
||||
if (!keyword) {
|
||||
keyword = `question-${index}`;
|
||||
}
|
||||
|
||||
let unique = keyword;
|
||||
let suffix = 1;
|
||||
while (seen.has(unique)) {
|
||||
unique = `${keyword}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
seen.add(unique);
|
||||
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: unique,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats clarification answers as a context message and sends it via onSend.
|
||||
*/
|
||||
export function buildClarificationAnswersMessage(
|
||||
answers: Record<string, string>,
|
||||
rawQuestions: Array<{ question: string; keyword: string }>,
|
||||
mode: "create" | "edit",
|
||||
): string {
|
||||
const contextMessage = rawQuestions
|
||||
.map((q) => {
|
||||
const answer = answers[q.keyword] || "";
|
||||
return `> ${q.question}\n\n${answer}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const action = mode === "create" ? "creating" : "editing";
|
||||
return `**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with ${action} the agent.`;
|
||||
}
|
||||
@@ -61,15 +61,11 @@ export const ConversationEmptyState = ({
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && (
|
||||
<div className="text-neutral-500 dark:text-neutral-400">{icon}</div>
|
||||
)}
|
||||
{icon && <div className="text-neutral-500">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -93,7 +89,7 @@ export const ConversationScrollButton = ({
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-white dark:dark:bg-neutral-950 dark:dark:hover:bg-neutral-800 dark:hover:bg-neutral-100",
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className,
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
@@ -45,8 +45,8 @@ export const MessageContent = ({
|
||||
className={cn(
|
||||
"is-user:dark flex w-full min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:w-fit",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-neutral-100 group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-neutral-950 dark:group-[.is-user]:bg-neutral-800 dark:group-[.is-user]:text-neutral-50",
|
||||
"group-[.is-assistant]:text-neutral-950 dark:group-[.is-assistant]:text-neutral-50",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-neutral-100 group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-neutral-950",
|
||||
"group-[.is-assistant]:text-neutral-950",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -291,7 +291,7 @@ export const MessageBranchPage = ({
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-neutral-500 shadow-none dark:text-neutral-400",
|
||||
"border-none bg-transparent text-neutral-500 shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Adapted from AI SDK Elements `prompt-input` component.
|
||||
* @see https://elements.ai-sdk.dev/components/prompt-input
|
||||
*
|
||||
* Stripped down to only the sub-components used by the copilot ChatInput:
|
||||
* PromptInput, PromptInputBody, PromptInputTextarea, PromptInputFooter,
|
||||
* PromptInputTools, PromptInputButton, PromptInputSubmit.
|
||||
*/
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import type {
|
||||
ComponentProps,
|
||||
FormEvent,
|
||||
FormEventHandler,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowUp as ArrowUpIcon,
|
||||
Stop as StopIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Children, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// PromptInput — form wrapper
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputProps = Omit<
|
||||
HTMLAttributes<HTMLFormElement>,
|
||||
"onSubmit"
|
||||
> & {
|
||||
onSubmit: (
|
||||
text: string,
|
||||
event: FormEvent<HTMLFormElement>,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function PromptInput({
|
||||
className,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) {
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const text = (formData.get("message") as string) || "";
|
||||
|
||||
const result = onSubmit(text, event);
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn("w-full", className)}
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
{...props}
|
||||
>
|
||||
<InputGroup className="overflow-hidden">{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputBody — content wrapper
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function PromptInputBody({ className, ...props }: PromptInputBodyProps) {
|
||||
return <div className={cn("contents", className)} {...props} />;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputTextarea — auto-resize textarea with Enter-to-submit
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<
|
||||
typeof InputGroupTextarea
|
||||
>;
|
||||
|
||||
export function PromptInputTextarea({
|
||||
onKeyDown,
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "Type your message...",
|
||||
value,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
function autoResize(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
// Resize when value changes externally (e.g. cleared after send)
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) autoResize(textareaRef.current);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
autoResize(e.currentTarget);
|
||||
onChange?.(e);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
// Call external handler first
|
||||
onKeyDown?.(e);
|
||||
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
if (isComposing || e.nativeEvent.isComposing) return;
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
|
||||
const { form } = e.currentTarget;
|
||||
const submitButton = form?.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLButtonElement | null;
|
||||
if (submitButton?.disabled) return;
|
||||
|
||||
form?.requestSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, isComposing],
|
||||
);
|
||||
|
||||
const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
|
||||
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"max-h-48 min-h-0 text-base leading-6 md:text-base",
|
||||
className,
|
||||
)}
|
||||
name="message"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputFooter — bottom bar
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputFooterProps = Omit<
|
||||
ComponentProps<typeof InputGroupAddon>,
|
||||
"align"
|
||||
>;
|
||||
|
||||
export function PromptInputFooter({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputFooterProps) {
|
||||
return (
|
||||
<InputGroupAddon
|
||||
align="block-end"
|
||||
className={cn("justify-between gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputTools — left-side button group
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function PromptInputTools({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputButton — tool button with optional tooltip
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputButtonTooltip =
|
||||
| string
|
||||
| {
|
||||
content: ReactNode;
|
||||
shortcut?: string;
|
||||
side?: ComponentProps<typeof TooltipContent>["side"];
|
||||
};
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
|
||||
tooltip?: PromptInputButtonTooltip;
|
||||
};
|
||||
|
||||
export function PromptInputButton({
|
||||
variant = "ghost",
|
||||
className,
|
||||
size,
|
||||
tooltip,
|
||||
...props
|
||||
}: PromptInputButtonProps) {
|
||||
const newSize =
|
||||
size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
|
||||
|
||||
const button = (
|
||||
<InputGroupButton
|
||||
className={cn(className)}
|
||||
size={newSize}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
const tooltipContent =
|
||||
typeof tooltip === "string" ? tooltip : tooltip.content;
|
||||
const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
|
||||
const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={side}>
|
||||
{tooltipContent}
|
||||
{shortcut && (
|
||||
<span className="ml-2 text-muted-foreground">{shortcut}</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PromptInputSubmit — send / stop button
|
||||
// ============================================================================
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
||||
status?: ChatStatus;
|
||||
onStop?: () => void;
|
||||
};
|
||||
|
||||
export function PromptInputSubmit({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "icon-sm",
|
||||
status,
|
||||
onStop,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) {
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canStop = isGenerating && Boolean(onStop);
|
||||
const isDisabled = Boolean(disabled) || (isGenerating && !canStop);
|
||||
|
||||
let Icon = <ArrowUpIcon className="size-4" weight="bold" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Spinner />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <StopIcon className="size-4" weight="bold" />;
|
||||
}
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (canStop && onStop) {
|
||||
e.preventDefault();
|
||||
onStop();
|
||||
return;
|
||||
}
|
||||
if (isGenerating) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
},
|
||||
[canStop, isGenerating, onStop, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label={canStop ? "Stop" : "Submit"}
|
||||
className={cn(
|
||||
"size-[2.625rem] rounded-full border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900 disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-100",
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type={canStop ? "button" : "submit"}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
</InputGroupButton>
|
||||
);
|
||||
}
|
||||
129
autogpt_platform/frontend/src/components/ui/input-group.tsx
Normal file
129
autogpt_platform/frontend/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex w-full items-center rounded-xlarge border border-neutral-200 bg-white shadow-sm outline-none transition-[color,box-shadow]",
|
||||
"min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-zinc-400 has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-zinc-400",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "order-first pl-3",
|
||||
"inline-end": "order-last pr-3",
|
||||
"block-start": "order-first w-full justify-start px-3 pt-3",
|
||||
"block-end": "order-last w-full justify-start px-3 pb-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
onClick?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("textarea")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex min-w-0 gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-md has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs": "size-6 rounded-md p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 rounded-md p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputGroupTextarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputGroupTextarea.displayName = "InputGroupTextarea";
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea };
|
||||
16
autogpt_platform/frontend/src/components/ui/spinner.tsx
Normal file
16
autogpt_platform/frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CircleNotch as CircleNotchIcon } from "@phosphor-icons/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...(props as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
22
autogpt_platform/frontend/src/components/ui/textarea.tsx
Normal file
22
autogpt_platform/frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-neutral-200 bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Unit tests for helpers.ts
|
||||
*
|
||||
* These tests validate the error handling in handleFetchError, specifically
|
||||
* the fix for the issue where calling response.json() on non-JSON responses
|
||||
* would throw: "Failed to execute 'json' on 'Response': Unexpected token 'A',
|
||||
* "A server e"... is not valid JSON"
|
||||
*
|
||||
* To run these tests, you'll need to set up a unit test framework like Jest or Vitest.
|
||||
*
|
||||
* Test cases to cover:
|
||||
*
|
||||
* 1. JSON error responses should be parsed correctly
|
||||
* - Given: Response with content-type: application/json
|
||||
* - When: handleFetchError is called
|
||||
* - Then: Should parse JSON and return ApiError with parsed response
|
||||
*
|
||||
* 2. Non-JSON error responses (e.g., HTML) should be handled gracefully
|
||||
* - Given: Response with content-type: text/html
|
||||
* - When: handleFetchError is called
|
||||
* - Then: Should read as text and return ApiError with text response
|
||||
*
|
||||
* 3. Response without content-type header should be handled
|
||||
* - Given: Response without content-type header
|
||||
* - When: handleFetchError is called
|
||||
* - Then: Should default to reading as text
|
||||
*
|
||||
* 4. JSON parsing errors should not throw
|
||||
* - Given: Response with content-type: application/json but HTML body
|
||||
* - When: handleFetchError is called and json() throws
|
||||
* - Then: Should catch error, log warning, and return ApiError with null response
|
||||
*
|
||||
* 5. Specific validation for the fixed bug
|
||||
* - Given: 502 Bad Gateway with content-type: application/json but HTML body
|
||||
* - When: response.json() throws "Unexpected token 'A'" error
|
||||
* - Then: Should NOT propagate the error, should return ApiError with null response
|
||||
*/
|
||||
|
||||
import { handleFetchError } from "./helpers";
|
||||
|
||||
// Manual test function - can be run in browser console or Node
|
||||
export async function testHandleFetchError() {
|
||||
console.log("Testing handleFetchError...");
|
||||
|
||||
// Test 1: JSON response
|
||||
const jsonResponse = new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
const error1 = await handleFetchError(jsonResponse);
|
||||
console.assert(
|
||||
error1.status === 500 && error1.response?.error === "Internal server error",
|
||||
"Test 1 failed: JSON response",
|
||||
);
|
||||
|
||||
// Test 2: HTML response
|
||||
const htmlResponse = new Response("<html><body>Server Error</body></html>", {
|
||||
status: 502,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
const error2 = await handleFetchError(htmlResponse);
|
||||
console.assert(
|
||||
error2.status === 502 &&
|
||||
typeof error2.response === "string" &&
|
||||
error2.response.includes("Server Error"),
|
||||
"Test 2 failed: HTML response",
|
||||
);
|
||||
|
||||
// Test 3: Mismatched content-type (claims JSON but is HTML)
|
||||
// This simulates the bug that was fixed
|
||||
const mismatchedResponse = new Response(
|
||||
"<html><body>A server error occurred</body></html>",
|
||||
{
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" }, // Claims JSON but isn't
|
||||
},
|
||||
);
|
||||
try {
|
||||
const error3 = await handleFetchError(mismatchedResponse);
|
||||
console.assert(
|
||||
error3.status === 502 && error3.response === null,
|
||||
"Test 3 failed: Mismatched content-type should return null response",
|
||||
);
|
||||
console.log("✓ All tests passed!");
|
||||
} catch (e) {
|
||||
console.error("✗ Test 3 failed: Should not throw error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to run manual tests
|
||||
// testHandleFetchError();
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
plugins: [tsconfigPaths(), react()],
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
include: ["src/**/*.test.tsx"],
|
||||
include: ["src/**/*.test.tsx", "src/**/*.test.ts"],
|
||||
setupFiles: ["./src/tests/integrations/vitest.setup.tsx"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user