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:
Ubbe
2026-02-27 23:24:19 +08:00
committed by GitHub
parent bf6308e87c
commit e8cca6cd9a
19 changed files with 858 additions and 373 deletions

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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;
});
}

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -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.`;
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
}

View 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 };

View 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 };

View 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 };

View File

@@ -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();

View File

@@ -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"],
},
});