Nan Gao bcaf6db36a
feat(models): add declarative reasoning capability contract (#5780)
* feat(models): add declarative reasoning capability contract (#5073)

Introduce an optional per-model `reasoning:` block beside the legacy
`supports_thinking` / `supports_reasoning_effort` booleans. It declares
thinking availability (unsupported | optional | required), the accepted
effort values with aliases, default, and serialization path, the payload
dialect, and the reasoning-history requirement. When present the booleans
are derived from it and contradictory profiles fail at config load.

`deerflow.models.reasoning` normalizes legacy and declared profiles into
one immutable contract and resolves every caller's generic request against
it. `create_chat_model` is the single enforcement point: required-thinking
models never receive a synthesized disable payload, unsupported effort
values never reach the provider, and the dialect synthesizes the on/off
payload when no template exists. Legacy profiles keep the historical path
byte-for-byte. The lead agent and subagent descriptor resolve the same
policy first so run metadata reports effective values.

`/api/models` and `DeerFlowClient` project the contract as `reasoning`;
the frontend derives mode clamping and effort choices from it through
`core/models/reasoning.ts`, and user preferences accept provider-specific
effort tokens. The setup wizard's Z.AI GLM-5.3-Flash profile migrates to
the contract, restoring its low/high/max effort control.

* docs(models): move the reasoning contract guidance into models/AGENTS.md

backend/AGENTS.md sits within 46 bytes of the 96 KiB effective-chain hard
limit for the middlewares, runtime, sandbox, and subagents guides, so the
new section pushed scripts/check_agent_guidance.py over it. The models
guide is the nearest file for the factory and the contract module, and
its chain has ample room.

* fix(models): address review on the reasoning contract

- vLLM dialect: the enable path now mirrors whichever chat_template_kwargs
  switch the template declares, like the disable path already did, so a
  template using the older `thinking` key no longer also grows
  `enable_thinking`.
- Validate operator-supplied effort values at `effort.path` inside
  `when_thinking_enabled`, `when_thinking_disabled`, and the `thinking`
  shortcut against the contract, not only the profile-level value; they are
  forwarded when the caller chooses nothing, so the "unknown values never
  reach the provider" guarantee now holds for them too.
- Constrain `effort.path` to dotted identifiers and reject single-segment
  paths that would replace a whole mapping (`extra_body`, `thinking`,
  `model_kwargs`, `default_headers`, `default_query`).
- Z.AI GLM-5.3-Flash profile: `default: high` instead of `max`, because the
  default also governs summarization, title generation, and subagent calls
  that never choose an effort; `max` stays selectable. Documented in the
  config example, the configuration manual, and the design note.

* fix(models): close reasoning contract review gaps

* fix(models): preserve native Ollama reasoning config

* fix(models): keep native reasoning strings and project reasoning into the descriptor

- `ModelConfig.reasoning` also accepts the level string ChatOllama takes
  for gpt-oss style models (`low` / `medium` / `high`); the factory forwards
  both the boolean and the string on the legacy path, so profiles that
  passed `reasoning: high` through before the contract field existed keep
  loading and keep sending it.
- The assembly descriptor no longer excludes `reasoning` from
  `model_parameters`: the native ChatOllama value and a declared contract's
  dialect/history/effort change the request payload, so they now move the
  assembly fingerprint.
- e2e: the sidecar spec pinned the pre-contract body where a model without
  effort control still received `reasoning_effort: minimal`; the preset is
  now dropped for such models (the Gateway discarded it anyway), so the
  assertion expects the key to be absent.
2026-09-24 20:39:30 +08:00

3119 lines
102 KiB
TypeScript

"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { useQueryClient } from "@tanstack/react-query";
import type { ChatStatus } from "ai";
import {
CheckIcon,
GraduationCapIcon,
LightbulbIcon,
Loader2Icon,
MicIcon,
PaperclipIcon,
PlusIcon,
RocketIcon,
SparklesIcon,
SquareIcon,
TargetIcon,
Undo2Icon,
XIcon,
ZapIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type ComponentProps,
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuItem,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputButton,
PromptInputFooter,
PromptInputHeader,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
usePromptInputController,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { fetch } from "@/core/api/fetcher";
import { useAuth } from "@/core/auth/AuthProvider";
import { getBackendBaseURL } from "@/core/config";
import {
buildConversationReferenceMetadata,
type ConversationReference,
} from "@/core/conversation-references";
import { useI18n } from "@/core/i18n/hooks";
import { polishInputDraft } from "@/core/input-polish/api";
import {
isHiddenFromUIMessage,
type FileInMessage,
} from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
getReasoningEffortOptions,
getResolvedMode,
type InputMode,
isThinkingRequired,
type ReasoningEffortValue,
reasoningEffortForMode,
resolveReasoningEffort,
supportsThinking as modelSupportsThinking,
} from "@/core/models/reasoning";
import { useStagedProjectAttachments } from "@/core/projects/composer-attach";
import {
buildReferenceMessageMetadata,
type SidecarContext,
} from "@/core/sidecar";
import { useSkills } from "@/core/skills/hooks";
import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api";
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
import type { AgentThreadContext, GoalState } from "@/core/threads";
import { compactThreadContext } from "@/core/threads/api";
import {
buildComposerDraftKey,
clearComposerDraft,
getSessionComposerDraftStorage,
readComposerDraft,
resolveComposerDraft,
type ComposerDraft,
writeComposerDraft,
} from "@/core/threads/composer-draft";
import { threadTokenUsageQueryKey } from "@/core/threads/token-usage";
import { textOfMessage } from "@/core/threads/utils";
import {
formatUploadSize,
splitUnsupportedUploadFiles,
useUploadLimits,
validateUploadLimits,
type UploadLimits,
type UploadLimitViolation,
} from "@/core/uploads";
import {
appendSpeechTranscript,
getSpeechRecognitionConstructor,
getSpeechRecognitionLanguage,
mapSpeechRecognitionError,
readSpeechRecognitionTranscript,
shouldRestartSpeechRecognition,
type BrowserSpeechRecognition,
type SpeechRecognitionErrorKind,
} from "@/core/voice-input/speech-recognition";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ConversationReferenceChip } from "./conversation-references/conversation-reference-chip";
import { ReferenceConversationsButton } from "./conversation-references/reference-conversations-button";
import {
abortGoalRequest,
beginGoalRequest,
canPolishInput,
createGoalRequestState,
findSuggestionTemplatePlaceholder,
finishGoalRequest,
filterSkillsForAgent,
getGoalObjectiveCounter,
getInputSubmitAction,
getLeadingSlashSkillQuery,
getMatchingSkillSuggestions,
type GoalCommand,
isAbortError,
isCurrentGoalRequest,
isGoalObjectiveTooLong,
MAX_GOAL_OBJECTIVE_CHARS,
readGoalResponseError,
type SlashSuggestion,
} from "./input-box-helpers";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import {
ModelPicker,
ModelPickerContent,
ModelPickerTrigger,
} from "./model-picker-content";
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
import { SlashSkillChip } from "./slash-skill-chip";
import { Tooltip } from "./tooltip";
const COMPOSER_DRAFT_SAVE_DELAY_MS = 300;
function focusContentEditableEnd(element: HTMLElement | null) {
if (!element) {
return;
}
element.focus();
const selection = window.getSelection();
if (!selection) {
return;
}
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
function insertPlainTextAtSelection(container: HTMLElement, text: string) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
const ancestor = range.commonAncestorContainer;
if (ancestor !== container && !container.contains(ancestor)) {
return false;
}
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.setEndAfter(node);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function escapeXmlAttribute(value: string) {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export type InputBoxSubmitOptions = {
additionalKwargs?: Record<string, unknown>;
additionalInputMessages?: Message[];
/** Thread IDs attached through the conversation picker; sent as run context. */
conversationReferences?: string[];
onSent?: () => void;
};
type VoiceRecognitionStartOptions = {
focusAfterStart?: boolean;
};
function buildHiddenConversationQuoteMessage({
contexts,
}: {
contexts: SidecarContext[];
}): Message {
return {
type: "human",
content: [
{
type: "text",
text: [
contexts.length === 1
? "The user added the following quoted context to this conversation."
: `The user added the following ${contexts.length} quoted contexts to this conversation.`,
"Use the referenced_message blocks as reference material for the user's next message.",
"",
...contexts.flatMap((context, index) =>
[
`<referenced_message index="${index + 1}" label="${escapeXmlAttribute(
context.label,
)}">`,
`Role: ${context.role === "user" ? "User" : "Assistant"}`,
context.messageId ? `Message ID: ${context.messageId}` : null,
"",
context.content,
"</referenced_message>",
"",
].filter((line): line is string => line !== null),
),
]
.filter((line): line is string => line !== null)
.join("\n"),
},
],
additional_kwargs: {
hide_from_ui: true,
conversation_quote_context: true,
// Keep ids/roles/count 1:1 parallel with `contexts` so consumers can zip
// them safely; do not dedupe ids here.
referenced_message_ids: contexts.map(
(context) => context.messageId ?? "",
),
referenced_message_roles: contexts.map((context) => context.role),
quote_context_count: contexts.length,
},
} as Message;
}
export function InputBox({
className,
disabled,
autoFocus,
status = "ready",
context,
extraHeader,
isWelcomeMode,
threadId,
draftThreadId = threadId,
draftAgentName,
defaultModelName,
knowledgeScopeControl,
initialValue,
onContextChange,
onFollowupsVisibilityChange,
onGoalChange,
onPrepareThread,
onSubmit,
onStop,
canStopStreaming = true,
canCreateRuns = true,
agentSkillNames,
agentSkillsLoading = false,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
status?: ChatStatus;
disabled?: boolean;
context: Omit<
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: ReasoningEffortValue;
};
extraHeader?: React.ReactNode;
/**
* Whether to render the input in welcome layout (vertically centered,
* with hero + quick action suggestions). This is purely a visual flag,
* decoupled from "the backend has created the thread" — see issue #2746.
*/
isWelcomeMode?: boolean;
threadId: string;
draftThreadId?: string;
draftAgentName?: string | null;
agentSkillNames?: string[] | null;
agentSkillsLoading?: boolean;
/**
* The active custom agent's configured default model, if any. Used as the
* auto-selection fallback so an agent chat honors the agent's own default
* model instead of silently snapping to the first configured model
* (issue #4336). ``null`` / undefined = no agent default → use models[0].
*/
defaultModelName?: string | null;
/** Optional knowledge-scope control rendered directly after mode. */
knowledgeScopeControl?: React.ReactNode;
initialValue?: string;
onContextChange?: (
// Explicit selections contain only the fields changed by that action,
// never the whole thread-resolved context (which may override the account).
context: Partial<
Omit<
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: ReasoningEffortValue;
}
>,
options?: { automatic: boolean },
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onGoalChange?: (goal: GoalState | null) => void;
/**
* Prepare a not-yet-materialized thread before a builtin command creates
* it server-side. The `/goal <condition>` PUT endpoint materializes a
* missing thread row itself, so a project-scoped new chat uses this to
* assign membership first — the later idempotent thread create would
* otherwise return that unassigned row without the project. Only runs for
* goal-set: status/clear never create a thread server-side. Rejecting
* aborts the command and keeps the composer's text for a retry.
*/
onPrepareThread?: () => void | Promise<void>;
onSubmit?: (
message: PromptInputMessage,
options?: InputBoxSubmitOptions,
) => void | Promise<void>;
onStop?: () => void;
/**
* Whether the caller's role holds `runs:cancel` (RFC #4063 Phase 4).
* Defaults to true so callers that don't resolve permissions (pre-Phase-4
* backends, storybook) keep today's behavior; the Gateway route guard
* stays the enforcement point.
*/
canStopStreaming?: boolean;
/**
* Whether the caller's role holds `runs:create` (RFC #4063 Phase 4).
* Defaults to true so callers that don't resolve permissions (pre-Phase-4
* backends, storybook) keep today's behavior; the Gateway route guard
* stays the enforcement point.
*/
canCreateRuns?: boolean;
}) {
const { locale, t } = useI18n();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const { user } = useAuth();
const { thread, isMock } = useThread();
const { attachments, textInput } = usePromptInputController();
const setTextInput = textInput.setInput;
const sidecar = useMaybeSidecar();
const attachmentParts = attachments.files;
// Conversations attached for the next message only. Not persisted with the
// draft; cleared once a send proceeds or the composer moves to another thread.
const [conversationReferences, setConversationReferences] = useState<
ConversationReference[]
>([]);
useEffect(() => {
setConversationReferences([]);
}, [threadId]);
const removeAttachment = attachments.remove;
// Project documents attached from the shelf arrive already ingested
// thread-side (spec §9): the composer shows them as completed attachments
// and includes them in the next send without a re-upload. Staged only on
// attach success; the hook consumes the staged entry once per thread and
// keeps it across a Strict-Mode effect replay.
const [projectAttachments, setProjectAttachments] =
useStagedProjectAttachments(threadId);
const { skills, isLoading: skillsLoading } = useSkills();
const { data: uploadLimits } = useUploadLimits(threadId);
const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inlineSkillTextRef = useRef<HTMLSpanElement | null>(null);
const inlineSkillComposingRef = useRef(false);
const goalRequestStateRef = useRef(createGoalRequestState());
const compactRequestStateRef = useRef(createGoalRequestState());
const inputPolishRequestRef = useRef<{
controller: AbortController | null;
sequence: number;
}>({
controller: null,
sequence: 0,
});
const voiceRecognitionRef = useRef<BrowserSpeechRecognition | null>(null);
const voiceBaseTextRef = useRef("");
const voiceLatestTextRef = useRef("");
const voiceLastErrorKindRef = useRef<SpeechRecognitionErrorKind | null>(null);
const voiceStopRequestedRef = useRef(false);
const voiceRestartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const startVoiceRecognitionRef = useRef<
((options?: VoiceRecognitionStartOptions) => boolean) | null
>(null);
const promptHistoryIndexRef = useRef<number | null>(null);
const promptHistoryDraftRef = useRef("");
const pendingDraftSubmissionKeyRef = useRef<string | null>(null);
const latestDraftRef = useRef<{
key: string;
draft: { text: string; skillName: string | null };
} | null>(null);
const draftSaveTimerRef = useRef<number | null>(null);
const draftSaveGenerationRef = useRef(0);
const [followups, setFollowups] = useState<string[]>([]);
const { data: suggestionsConfig } = useSuggestionsConfig();
const suggestionsConfigLoaded = suggestionsConfig !== undefined;
const suggestionsEnabled = suggestionsConfig?.enabled;
const maxFollowupSuggestions =
suggestionsConfig?.max_suggestions ?? DEFAULT_MAX_SUGGESTIONS;
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [polishingInput, setPolishingInput] = useState(false);
const [voiceListening, setVoiceListening] = useState(false);
const [inputPolishUndo, setInputPolishUndo] = useState<{
originalText: string;
rewrittenText: string;
} | null>(null);
const [textareaFocused, setTextareaFocused] = useState(false);
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
const [selectedSlashSkill, setSelectedSlashSkill] =
useState<SlashSuggestion | null>(null);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
// Set when the user stops a streaming turn. Such a turn ends on a
// half-finished response, so we must NOT generate follow-up suggestions for it.
const stoppedByUserRef = useRef(false);
const messagesRef = useRef(thread.messages);
const clearVoiceRestartTimer = useCallback(() => {
if (voiceRestartTimerRef.current === null) {
return;
}
clearTimeout(voiceRestartTimerRef.current);
voiceRestartTimerRef.current = null;
}, []);
const cleanupVoiceRecognition = useCallback(
(
recognition: BrowserSpeechRecognition | null,
options: { keepListening?: boolean } = {},
) => {
clearVoiceRestartTimer();
if (!recognition) {
if (!options.keepListening) {
voiceLastErrorKindRef.current = null;
voiceStopRequestedRef.current = false;
setVoiceListening(false);
}
return;
}
recognition.onend = null;
recognition.onerror = null;
recognition.onresult = null;
if (voiceRecognitionRef.current === recognition) {
voiceRecognitionRef.current = null;
}
if (!options.keepListening) {
voiceLastErrorKindRef.current = null;
voiceStopRequestedRef.current = false;
setVoiceListening(false);
}
},
[clearVoiceRestartTimer],
);
const abortVoiceInput = useCallback(() => {
const recognition = voiceRecognitionRef.current;
voiceStopRequestedRef.current = true;
if (!recognition) {
cleanupVoiceRecognition(null);
return;
}
cleanupVoiceRecognition(recognition);
try {
recognition.abort();
} catch {
// Browser implementations can throw when the recognizer already ended.
}
}, [cleanupVoiceRecognition]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
const builtinSlashCommands = useMemo<SlashSuggestion[]>(
() => [
{
name: "goal",
description: t.inputBox.goalCommandDescription,
kind: "builtin",
},
{
name: "compact",
description: t.inputBox.compactCommandDescription,
kind: "builtin",
},
],
[t.inputBox.compactCommandDescription, t.inputBox.goalCommandDescription],
);
const reportUploadLimitViolations = useCallback(
(violations: UploadLimitViolation[]) => {
for (const violation of violations) {
if (violation.code === "max_file_size") {
toast.error(
t.uploads.filesTooLarge(
violation.files.map((file) => file.name).join(", "),
formatUploadSize(violation.limit),
),
);
} else if (violation.code === "max_files") {
toast.error(
t.uploads.tooManyFiles(violation.files.length, violation.limit),
);
} else {
toast.error(
t.uploads.totalSizeTooLarge(
violation.files.length,
formatUploadSize(violation.limit),
),
);
}
}
},
[t.uploads],
);
useEffect(() => {
if (!uploadLimits) {
return;
}
const attachmentEntries = attachmentParts.flatMap((attachment) =>
attachment.file instanceof File
? [{ id: attachment.id, file: attachment.file }]
: [],
);
const validation = validateUploadLimits(
[],
attachmentEntries.map(({ file }) => file),
uploadLimits,
);
if (validation.rejected.length === 0) {
return;
}
const rejected = new Set(validation.rejected);
for (const entry of attachmentEntries) {
if (rejected.has(entry.file)) {
removeAttachment(entry.id);
}
}
reportUploadLimitViolations(validation.violations);
}, [
attachmentParts,
removeAttachment,
reportUploadLimitViolations,
uploadLimits,
]);
useEffect(() => {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
// Prefer the active agent's configured default model over models[0] as the
// auto-selection fallback, so an agent chat respects the agent's own
// default instead of snapping to the first model (issue #4336).
const agentDefaultModel = defaultModelName
? models.find((m) => m.name === defaultModelName)
: undefined;
const fallbackModel = currentModel ?? agentDefaultModel ?? models[0]!;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, fallbackModel);
// A remembered effort may not exist in this model's contract (issue #5073);
// map it through the aliases / default instead of sending it verbatim.
const nextEffort = resolveReasoningEffort(
fallbackModel,
context.reasoning_effort,
);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextEffort
) {
return;
}
onContextChange?.(
{
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextEffort,
},
{ automatic: true },
);
}, [context, models, defaultModelName, onContextChange]);
const selectedModel = useMemo(() => {
if (models.length === 0) {
return undefined;
}
return models.find((m) => m.name === context.model_name) ?? models[0];
}, [context.model_name, models]);
const resolvedModelName = selectedModel?.name;
const supportThinking = useMemo(
() => modelSupportsThinking(selectedModel),
[selectedModel],
);
const thinkingRequired = useMemo(
() => isThinkingRequired(selectedModel),
[selectedModel],
);
// Effort choices come from the model's reasoning contract (issue #5073), so
// a provider that only accepts e.g. low/high/max never sees a generic value.
const reasoningEffortOptions = useMemo(
() => getReasoningEffortOptions(selectedModel),
[selectedModel],
);
const supportReasoningEffort = reasoningEffortOptions.length > 0;
const effectiveReasoningEffort = useMemo(
() =>
resolveReasoningEffort(
selectedModel,
context.reasoning_effort ??
reasoningEffortForMode(context.mode ?? "pro", selectedModel),
),
[context.mode, context.reasoning_effort, selectedModel],
);
const reasoningEffortLabel = useCallback(
(effort: string | undefined) => {
switch (effort) {
case "minimal":
return t.inputBox.reasoningEffortMinimal;
case "low":
return t.inputBox.reasoningEffortLow;
case "medium":
return t.inputBox.reasoningEffortMedium;
case "high":
return t.inputBox.reasoningEffortHigh;
case "xhigh":
return t.inputBox.reasoningEffortXhigh;
case "max":
return t.inputBox.reasoningEffortMax;
default:
return effort ?? "";
}
},
[t],
);
const reasoningEffortDescription = useCallback(
(effort: string) => {
switch (effort) {
case "minimal":
return t.inputBox.reasoningEffortMinimalDescription;
case "low":
return t.inputBox.reasoningEffortLowDescription;
case "medium":
return t.inputBox.reasoningEffortMediumDescription;
case "high":
return t.inputBox.reasoningEffortHighDescription;
case "xhigh":
return t.inputBox.reasoningEffortXhighDescription;
case "max":
return t.inputBox.reasoningEffortMaxDescription;
default:
return null;
}
},
[t],
);
const draftKey = useMemo(
() =>
buildComposerDraftKey({
userId: user?.id ?? "anonymous",
agentName:
draftAgentName ??
(typeof context.agent_name === "string" ? context.agent_name : null),
threadId: draftThreadId,
}),
[context.agent_name, draftAgentName, draftThreadId, user?.id],
);
const agentScopedSkills = useMemo(
() =>
agentSkillsLoading ? [] : filterSkillsForAgent(skills, agentSkillNames),
[agentSkillNames, agentSkillsLoading, skills],
);
const enabledSkillNames = useMemo(
() =>
new Set(
agentScopedSkills
.filter((skill) => skill.enabled)
.map((skill) => skill.name),
),
[agentScopedSkills],
);
const cancelDraftSaveTimer = useCallback(() => {
if (draftSaveTimerRef.current === null) {
return;
}
window.clearTimeout(draftSaveTimerRef.current);
draftSaveTimerRef.current = null;
}, []);
const invalidateDraftSaveTimer = useCallback(() => {
draftSaveGenerationRef.current += 1;
cancelDraftSaveTimer();
}, [cancelDraftSaveTimer]);
const scheduleDraftSave = useCallback(
(draft: ComposerDraft, key = draftKey) => {
if (
!draft.text &&
!draft.skillName &&
pendingDraftSubmissionKeyRef.current === key
) {
return null;
}
if (draft.text || draft.skillName) {
pendingDraftSubmissionKeyRef.current = null;
}
latestDraftRef.current = { key, draft };
cancelDraftSaveTimer();
draftSaveGenerationRef.current += 1;
const generation = draftSaveGenerationRef.current;
const timer = window.setTimeout(() => {
if (
draftSaveGenerationRef.current !== generation ||
draftSaveTimerRef.current !== timer
) {
return;
}
draftSaveTimerRef.current = null;
writeComposerDraft(getSessionComposerDraftStorage(), key, draft);
}, COMPOSER_DRAFT_SAVE_DELAY_MS);
draftSaveTimerRef.current = timer;
return timer;
},
[cancelDraftSaveTimer, draftKey],
);
const flushLatestDraft = useCallback(
(expectedKey?: string) => {
const latest = latestDraftRef.current;
if (!latest || (expectedKey && latest.key !== expectedKey)) {
return;
}
cancelDraftSaveTimer();
writeComposerDraft(
getSessionComposerDraftStorage(),
latest.key,
latest.draft,
);
},
[cancelDraftSaveTimer],
);
const promptHistory = useMemo(() => {
const history: string[] = [];
for (const message of thread.messages) {
if (message.type !== "human") {
continue;
}
const additionalKwargs = message.additional_kwargs;
if (
additionalKwargs &&
typeof additionalKwargs === "object" &&
Reflect.get(additionalKwargs, "hide_from_ui") === true
) {
continue;
}
const text = textOfMessage(message)?.trim();
if (!text) {
continue;
}
if (history.at(-1) !== text) {
history.push(text);
}
}
return history;
}, [thread.messages]);
useLayoutEffect(() => {
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setTextInput("");
setSelectedSlashSkill(null);
setInputPolishUndo(null);
setHydratedDraftKey(null);
pendingDraftSubmissionKeyRef.current = null;
latestDraftRef.current = null;
invalidateDraftSaveTimer();
return () => flushLatestDraft(draftKey);
}, [draftKey, flushLatestDraft, invalidateDraftSaveTimer, setTextInput]);
useLayoutEffect(() => {
const handlePageHide = () => flushLatestDraft();
window.addEventListener("pagehide", handlePageHide);
return () => window.removeEventListener("pagehide", handlePageHide);
}, [flushLatestDraft]);
useEffect(() => {
if (skillsLoading || agentSkillsLoading || hydratedDraftKey === draftKey) {
return;
}
const savedDraft = readComposerDraft(
getSessionComposerDraftStorage(),
draftKey,
);
if (!savedDraft) {
if (!textInput.value && initialValue) {
setTextInput(initialValue);
}
setHydratedDraftKey(draftKey);
return;
}
const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames);
setTextInput(resolvedDraft.text);
const restoredSkill = resolvedDraft.skillName
? agentScopedSkills.find(
(skill) => skill.enabled && skill.name === resolvedDraft.skillName,
)
: undefined;
setSelectedSlashSkill(
restoredSkill
? {
name: restoredSkill.name,
description: restoredSkill.description,
kind: "skill",
}
: null,
);
setHydratedDraftKey(draftKey);
}, [
draftKey,
enabledSkillNames,
hydratedDraftKey,
initialValue,
setTextInput,
agentScopedSkills,
agentSkillsLoading,
skillsLoading,
textInput.value,
]);
useEffect(() => {
if (hydratedDraftKey !== draftKey) {
return;
}
const draft: ComposerDraft = {
text: textInput.value ?? "",
skillName:
selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null,
};
const timer = scheduleDraftSave(draft, draftKey);
return () => {
if (timer === null) {
return;
}
window.clearTimeout(timer);
if (draftSaveTimerRef.current === timer) {
draftSaveTimerRef.current = null;
}
};
}, [
draftKey,
hydratedDraftKey,
scheduleDraftSave,
selectedSlashSkill,
textInput.value,
]);
useEffect(() => {
const goalRequestState = goalRequestStateRef.current;
const compactRequestState = compactRequestStateRef.current;
return () => {
abortGoalRequest(goalRequestState);
abortGoalRequest(compactRequestState);
};
}, [threadId]);
const abortInputPolishRequest = useCallback(() => {
inputPolishRequestRef.current.controller?.abort();
inputPolishRequestRef.current.controller = null;
inputPolishRequestRef.current.sequence += 1;
setPolishingInput(false);
}, []);
useEffect(() => {
return () => abortInputPolishRequest();
}, [abortInputPolishRequest, threadId]);
useEffect(() => {
const currentIndex = promptHistoryIndexRef.current;
if (currentIndex !== null && currentIndex >= promptHistory.length) {
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
}
}, [promptHistory.length]);
const handleModelSelect = useCallback(
(model_name: string) => {
if (disabled || polishingInput) {
return;
}
const model = models.find((m) => m.name === model_name);
if (!model) {
return;
}
const mode = getResolvedMode(context.mode, model);
const reasoning_effort = resolveReasoningEffort(
model,
context.reasoning_effort,
);
onContextChange?.({
model_name,
...(mode !== context.mode ? { mode } : {}),
...(reasoning_effort !== context.reasoning_effort
? { reasoning_effort }
: {}),
});
setModelDialogOpen(false);
},
[disabled, onContextChange, context, models, polishingInput],
);
const handleModeSelect = useCallback(
(mode: InputMode) => {
if (disabled || polishingInput) {
return;
}
const nextMode = getResolvedMode(mode, selectedModel);
onContextChange?.({
mode: nextMode,
reasoning_effort: reasoningEffortForMode(nextMode, selectedModel),
});
},
[disabled, onContextChange, polishingInput, selectedModel],
);
const handleReasoningEffortSelect = useCallback(
(effort: ReasoningEffortValue) => {
if (disabled || polishingInput) {
return;
}
onContextChange?.({
reasoning_effort: effort,
});
},
[disabled, onContextChange, polishingInput],
);
const handleGoalCommand = useCallback(
async (command: GoalCommand): Promise<boolean> => {
const request = beginGoalRequest(goalRequestStateRef.current, threadId);
const signal = request.controller.signal;
try {
let goal: GoalState | null = null;
if (command.kind === "status") {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{ method: "GET", signal },
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
goal =
((await response.json()) as { goal?: GoalState | null }).goal ??
null;
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
const objective = goal?.objective;
toast.info(
objective !== undefined
? // Function replacer so a goal containing `$&`/`$1` isn't
// interpreted as a replacement pattern.
t.inputBox.goalActive.replace("{goal}", () => objective)
: t.inputBox.goalNone,
);
onGoalChange?.(goal);
} else if (command.kind === "clear") {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{ method: "DELETE", signal },
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
toast.success(t.inputBox.goalCleared);
onGoalChange?.(null);
} else {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objective: command.objective }),
signal,
},
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
goal =
((await response.json()) as { goal?: GoalState | null }).goal ??
null;
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
toast.success(t.inputBox.goalSet);
onGoalChange?.(goal);
}
textInput.setInput("");
return true;
} catch (error) {
if (
isAbortError(error) ||
!isCurrentGoalRequest(goalRequestStateRef.current, request, threadId)
) {
return false;
}
toast.error(
error instanceof Error ? error.message : t.inputBox.goalFailed,
);
return false;
} finally {
finishGoalRequest(goalRequestStateRef.current, request);
}
},
[
onGoalChange,
t.inputBox.goalActive,
t.inputBox.goalCleared,
t.inputBox.goalFailed,
t.inputBox.goalNone,
t.inputBox.goalSet,
textInput,
threadId,
],
);
const handleCompactCommand = useCallback(async (): Promise<void> => {
if (isWelcomeMode) {
textInput.setInput("");
toast.info(t.inputBox.compactSkipped);
return;
}
const request = beginGoalRequest(compactRequestStateRef.current, threadId);
const signal = request.controller.signal;
try {
const result = await compactThreadContext(threadId, {
signal,
agentName:
typeof context.agent_name === "string" ? context.agent_name : null,
modelName:
typeof context.model_name === "string" ? context.model_name : null,
});
if (
!isCurrentGoalRequest(compactRequestStateRef.current, request, threadId)
) {
return;
}
textInput.setInput("");
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
void queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
void queryClient.invalidateQueries({
queryKey: threadTokenUsageQueryKey(threadId),
});
if (result.compacted) {
toast.success(t.inputBox.compactSuccess);
} else {
toast.info(t.inputBox.compactSkipped);
}
} catch (error) {
if (
isAbortError(error) ||
!isCurrentGoalRequest(compactRequestStateRef.current, request, threadId)
) {
return;
}
toast.error(
error instanceof Error ? error.message : t.inputBox.compactFailed,
);
} finally {
finishGoalRequest(compactRequestStateRef.current, request);
}
}, [
context.agent_name,
context.model_name,
queryClient,
t.inputBox.compactFailed,
t.inputBox.compactSkipped,
t.inputBox.compactSuccess,
isWelcomeMode,
textInput,
threadId,
]);
const submitThreadMessage = useCallback(
(message: PromptInputMessage) => {
const files = message.files.flatMap((file) =>
file.file instanceof File ? [file.file] : [],
);
const uploadValidation = validateUploadLimits([], files, uploadLimits);
if (uploadValidation.violations.length > 0) {
reportUploadLimitViolations(uploadValidation.violations);
return Promise.reject(new Error("Attachment limits exceeded."));
}
const placeholder = findSuggestionTemplatePlaceholder(message.text);
if (placeholder) {
toast.warning(t.inputBox.suggestionPlaceholderRequired);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(placeholder.start, placeholder.end);
});
return Promise.reject(
new Error("Suggestion template placeholder is unresolved."),
);
}
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setInputPolishUndo(null);
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
const quotes = sidecar?.conversationQuotes ?? [];
const quoteIds = quotes.map((quote) => quote.id);
const quoteContexts = quotes.map((quote) => quote.context);
pendingDraftSubmissionKeyRef.current = draftKey;
const referenceIds = conversationReferences.map(
(reference) => reference.threadId,
);
// Project-shelf attachments are already ingested thread-side (§9):
// they join ``additional_kwargs.files`` as completed uploads without a
// re-upload, and merge with any files uploaded in this send
// (buildThreadSubmitMessages concatenates the two lists).
const stagedFiles: FileInMessage[] = projectAttachments.map(
(attachment) => ({
filename: attachment.filename,
size: attachment.size_bytes,
path: attachment.virtual_path,
status: "uploaded" as const,
}),
);
const additionalKwargs = {
...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}),
...(referenceIds.length
? buildConversationReferenceMetadata(conversationReferences)
: {}),
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
};
const submitOptions: InputBoxSubmitOptions = {
...(Object.keys(additionalKwargs).length ? { additionalKwargs } : {}),
...(quotes.length
? {
additionalInputMessages: [
buildHiddenConversationQuoteMessage({
contexts: quoteContexts,
}),
],
}
: {}),
...(referenceIds.length
? { conversationReferences: referenceIds }
: {}),
// Clear one-time state only once the send genuinely proceeds. If the
// send is dropped by the in-flight guard, `onSent` never fires.
onSent: () => {
if (pendingDraftSubmissionKeyRef.current === draftKey) {
pendingDraftSubmissionKeyRef.current = null;
latestDraftRef.current = null;
invalidateDraftSaveTimer();
clearComposerDraft(getSessionComposerDraftStorage(), draftKey);
}
sidecar?.clearConversationQuotes(quoteIds);
setConversationReferences([]);
setProjectAttachments([]);
},
};
const submit = () => onSubmit?.(message, submitOptions);
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.(
{
...context,
model_name: resolvedModelName,
mode: getResolvedMode(context.mode, selectedModel),
},
{ automatic: true },
);
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
Promise.resolve(submit()).then(resolve).catch(reject);
}, 0);
});
}
return submit();
},
[
context,
conversationReferences,
draftKey,
invalidateDraftSaveTimer,
onContextChange,
onSubmit,
projectAttachments,
setProjectAttachments,
reportUploadLimitViolations,
resolvedModelName,
selectedModel,
sidecar,
t.inputBox.suggestionPlaceholderRequired,
uploadLimits,
],
);
const handleStopStreaming = useCallback(() => {
// Roles denied runs:cancel must not interrupt the in-progress turn —
// the Gateway would 403 the cancel anyway. The submit-button click is
// the only live entry point today (handleSubmit returns early with the
// pleaseWaitStreaming toast while streaming), but gate in the handler
// as defense-in-depth so any future stop path is covered too.
if (!canStopStreaming) {
return;
}
// Mark the in-progress turn as user-interrupted so the next
// streaming->ready transition does not suggest follow-ups for it.
stoppedByUserRef.current = true;
setFollowups([]);
setFollowupsHidden(true);
setFollowupsLoading(false);
onStop?.();
}, [canStopStreaming, onStop]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
toast.info(t.inputBox.pleaseWaitStreaming);
return Promise.reject(new Error("streaming"));
}
abortVoiceInput();
const messageWithSlashSkill = selectedSlashSkill
? {
...message,
text: `/${selectedSlashSkill.name} ${message.text}`,
}
: message;
const submitAction = getInputSubmitAction({
text: messageWithSlashSkill.text,
// Staged project-shelf attachments count exactly like uploaded
// files: submitThreadMessage maps them into the outgoing message's
// ``additional_kwargs.files``, so an attachment-only submit must not
// read as empty, and /goal or /compact must not intercept while an
// attach chip is present.
fileCount:
messageWithSlashSkill.files.length + projectAttachments.length,
status,
});
// Check run-starting actions before goal preparation or persistence:
// saving a goal also clears the draft and announces success. Status,
// clear, and compact commands do not start runs and keep their own gates.
if (
!canCreateRuns &&
(submitAction.kind === "message" ||
(submitAction.kind === "goal" && submitAction.command.kind === "set"))
) {
toast.info(t.inputBox.startTurnUnavailable);
return Promise.reject(new Error("runs-create-denied"));
}
if (submitAction.kind === "goal") {
if (
submitAction.command.kind === "set" &&
isGoalObjectiveTooLong(submitAction.command.objective)
) {
toast.error(
t.inputBox.goalTooLong.replace("{max}", () =>
String(MAX_GOAL_OBJECTIVE_CHARS),
),
);
// Reject so the composer keeps the user's text for editing instead of
// clearing it (PromptInput only preserves input on a rejected submit).
return Promise.reject(new Error("goal-too-long"));
}
if (submitAction.command.kind === "set") {
// A goal-set PUT creates the thread server-side when missing, so a
// project-scoped new chat must assign membership first. The prepare
// callback toasts its own failure; reject so the composer keeps the
// text for a retry instead of issuing an unassigned goal.
//
// Fence against conversation switches while preparation runs: the
// goal PUT registers its AbortController only when it starts, so
// the thread-change/unmount cleanup cannot cancel an in-flight
// prepare. Capture the goal-request epoch before the await — the
// cleanup bumps it via abortGoalRequest — and drop the stale
// continuation before it can clear the new conversation's composer
// or launch the abandoned submission.
const requestEpoch = goalRequestStateRef.current.sequence;
try {
await onPrepareThread?.();
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error("thread preparation failed"),
);
}
if (goalRequestStateRef.current.sequence !== requestEpoch) {
// Reject (not resolve) so PromptInput keeps the current
// conversation's composer text untouched.
return Promise.reject(new Error("goal-preparation-stale"));
}
}
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
const saved = await handleGoalCommand(submitAction.command);
// Only start a run when a goal was actually saved; status/clear never run.
if (saved && submitAction.command.kind === "set") {
return submitThreadMessage({
...message,
text: submitAction.command.objective,
files: [],
});
}
return;
}
if (submitAction.kind === "compact") {
return handleCompactCommand();
}
if (submitAction.kind === "empty") {
return;
}
await submitThreadMessage(messageWithSlashSkill);
if (selectedSlashSkill) {
setSelectedSlashSkill(null);
}
},
[
abortVoiceInput,
canCreateRuns,
handleCompactCommand,
handleGoalCommand,
onPrepareThread,
projectAttachments.length,
selectedSlashSkill,
status,
submitThreadMessage,
t.inputBox.goalTooLong,
t.inputBox.pleaseWaitStreaming,
t.inputBox.startTurnUnavailable,
],
);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
if (current) {
setPendingSuggestion(suggestion);
setConfirmOpen(true);
return;
}
textInput.setInput(suggestion);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
textInput.setInput(pendingSuggestion);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const confirmAppendAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
const current = (textInput.value ?? "").trim();
const next = current
? `${current}\n${pendingSuggestion}`
: pendingSuggestion;
textInput.setInput(next);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const slashSkillQuery = useMemo(
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
[textInput.value],
);
const goalObjectiveCounter = useMemo(
() => getGoalObjectiveCounter(textInput.value ?? ""),
[textInput.value],
);
const skillSuggestions = useMemo(() => {
if (slashSkillQuery === null) {
return [];
}
const matches = getMatchingSkillSuggestions(
agentScopedSkills,
slashSkillQuery,
builtinSlashCommands,
);
// Builtin commands own the whole composer line, so they cannot be combined
// with a skill activation: `/goal` behind a selected skill would submit as
// chat text instead of running the command. Drop them from the result
// rather than withholding them from the helper, which needs the list to
// reserve their names — a skill named after a builtin is unusable for the
// mirrored reason, its submitted text runs the command, not the skill.
return selectedSlashSkill
? matches.filter(({ kind }) => kind === "skill")
: matches;
}, [
agentScopedSkills,
builtinSlashCommands,
selectedSlashSkill,
slashSkillQuery,
]);
// A selected skill does not close the catalog: `/` reopens it so a skill can
// be found by browsing and swapped without first clearing the chip.
const showSkillSuggestions =
!disabled &&
textareaFocused &&
slashSkillQuery !== null &&
skillSuggestions.length > 0 &&
dismissedSkillSuggestionValue !== textInput.value;
const isComposerDisabled = disabled === true;
const isMockThread = isMock === true;
const composerLocked = isComposerDisabled || polishingInput;
// A denied runs:cancel role sees a disabled stop affordance, not a removed
// one — the composer must still show that a turn is in flight.
const stopDenied = status === "streaming" && !canStopStreaming;
// Mirror for runs:create on the send side. While streaming the button is
// the stop affordance (gated above), so the send denial only applies to
// the send state.
const sendDenied = status !== "streaming" && !canCreateRuns;
const inputPolishUndoAvailable =
!polishingInput &&
inputPolishUndo !== null &&
(textInput.value ?? "") === inputPolishUndo.rewrittenText;
const inputPolishDisabled =
isComposerDisabled ||
isMockThread ||
polishingInput ||
(!inputPolishUndoAvailable &&
(status === "streaming" ||
slashSkillQuery !== null ||
!canPolishInput(textInput.value ?? "")));
const speechRecognitionConstructor = useMemo(
() =>
typeof window === "undefined"
? null
: getSpeechRecognitionConstructor(window),
[],
);
const voiceInputSupported = speechRecognitionConstructor !== null;
const getVoiceInputErrorMessage = useCallback(
(kind: SpeechRecognitionErrorKind) => {
switch (kind) {
case "permission_denied":
return t.inputBox.voiceInputPermissionDenied;
case "microphone_unavailable":
return t.inputBox.voiceInputMicrophoneUnavailable;
case "unsupported_language":
return t.inputBox.voiceInputUnsupportedLanguage;
case "network":
return t.inputBox.voiceInputNetworkError;
case "no_speech":
return t.inputBox.voiceInputNoSpeech;
case "cancelled":
return null;
default:
return t.inputBox.voiceInputFailed;
}
},
[t],
);
const startVoiceRecognition = useCallback(
(options: VoiceRecognitionStartOptions = {}) => {
if (composerLocked || !speechRecognitionConstructor) {
return false;
}
const recognition = new speechRecognitionConstructor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = getSpeechRecognitionLanguage(locale);
recognition.maxAlternatives = 1;
voiceLastErrorKindRef.current = null;
voiceLatestTextRef.current = voiceBaseTextRef.current;
voiceRecognitionRef.current = recognition;
recognition.onresult = (event) => {
if (voiceRecognitionRef.current !== recognition) {
return;
}
const transcript = readSpeechRecognitionTranscript(event.results).text;
const nextValue = appendSpeechTranscript(
voiceBaseTextRef.current,
transcript,
);
voiceLatestTextRef.current = nextValue;
textInput.setInput(nextValue);
};
recognition.onerror = (event) => {
const errorKind = mapSpeechRecognitionError(event.error);
voiceLastErrorKindRef.current = errorKind;
if (
!voiceStopRequestedRef.current &&
shouldRestartSpeechRecognition(errorKind)
) {
return;
}
const message = getVoiceInputErrorMessage(errorKind);
if (message) {
toast.error(message);
}
};
recognition.onend = () => {
const shouldRestart =
voiceRecognitionRef.current === recognition &&
!voiceStopRequestedRef.current &&
shouldRestartSpeechRecognition(voiceLastErrorKindRef.current);
if (shouldRestart) {
voiceBaseTextRef.current = voiceLatestTextRef.current;
cleanupVoiceRecognition(recognition, { keepListening: true });
voiceRestartTimerRef.current = setTimeout(() => {
voiceRestartTimerRef.current = null;
if (voiceStopRequestedRef.current) {
cleanupVoiceRecognition(null);
return;
}
const restarted = startVoiceRecognitionRef.current?.() ?? false;
if (!restarted) {
cleanupVoiceRecognition(null);
}
}, 150);
return;
}
cleanupVoiceRecognition(recognition);
};
setVoiceListening(true);
try {
recognition.start();
if (options.focusAfterStart) {
requestAnimationFrame(() => {
if (selectedSlashSkill) {
focusContentEditableEnd(inlineSkillTextRef.current);
} else {
textareaRef.current?.focus();
}
});
}
return true;
} catch {
cleanupVoiceRecognition(recognition);
toast.error(t.inputBox.voiceInputFailed);
return false;
}
},
[
cleanupVoiceRecognition,
composerLocked,
getVoiceInputErrorMessage,
locale,
selectedSlashSkill,
speechRecognitionConstructor,
t.inputBox.voiceInputFailed,
textInput,
],
);
useEffect(() => {
startVoiceRecognitionRef.current = startVoiceRecognition;
}, [startVoiceRecognition]);
const stopVoiceInput = useCallback(() => {
const recognition = voiceRecognitionRef.current;
voiceStopRequestedRef.current = true;
if (!recognition) {
cleanupVoiceRecognition(null);
return;
}
try {
recognition.stop();
} catch {
cleanupVoiceRecognition(recognition);
}
}, [cleanupVoiceRecognition]);
const toggleVoiceInput = useCallback(() => {
if (voiceListening) {
stopVoiceInput();
return;
}
if (composerLocked) {
return;
}
if (!speechRecognitionConstructor) {
toast.error(t.inputBox.voiceInputUnsupported);
return;
}
abortInputPolishRequest();
setInputPolishUndo(null);
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
voiceStopRequestedRef.current = false;
voiceBaseTextRef.current = textInput.value ?? "";
voiceLatestTextRef.current = voiceBaseTextRef.current;
startVoiceRecognition({ focusAfterStart: true });
}, [
abortInputPolishRequest,
composerLocked,
speechRecognitionConstructor,
startVoiceRecognition,
stopVoiceInput,
t.inputBox.voiceInputUnsupported,
textInput,
voiceListening,
]);
useEffect(() => {
if (composerLocked && voiceListening) {
stopVoiceInput();
}
}, [composerLocked, stopVoiceInput, voiceListening]);
useEffect(() => {
return () => abortVoiceInput();
}, [abortVoiceInput, threadId]);
useEffect(() => {
setSkillSuggestionIndex(0);
}, [slashSkillQuery, skillSuggestions.length]);
const applySkillSuggestion = useCallback(
(suggestion: SlashSuggestion) => {
if (suggestion.kind === "skill") {
setSelectedSlashSkill(suggestion);
textInput.setInput("");
setDismissedSkillSuggestionValue(null);
requestAnimationFrame(() => {
focusContentEditableEnd(inlineSkillTextRef.current);
});
return;
}
const nextValue = `/${suggestion.name} `;
textInput.setInput(nextValue);
setDismissedSkillSuggestionValue(nextValue);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
});
},
[textInput],
);
const handleSkillSuggestionKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (!showSkillSuggestions) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSkillSuggestionIndex(
(index) => (index + 1) % skillSuggestions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSkillSuggestionIndex(
(index) =>
(index - 1 + skillSuggestions.length) % skillSuggestions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
if (event.shiftKey) {
return;
}
event.preventDefault();
const selectedSkill = skillSuggestions[skillSuggestionIndex];
if (selectedSkill) {
applySkillSuggestion(selectedSkill);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setDismissedSkillSuggestionValue(textInput.value);
}
},
[
applySkillSuggestion,
showSkillSuggestions,
skillSuggestionIndex,
skillSuggestions,
textInput.value,
],
);
const setPromptHistoryValue = useCallback(
(value: string) => {
textInput.setInput(value);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(value.length, value.length);
});
},
[textInput],
);
const handlePolishInput = useCallback(async () => {
if (inputPolishDisabled) {
return;
}
const originalText = textInput.value ?? "";
const controller = new AbortController();
inputPolishRequestRef.current.controller?.abort();
const sequence = inputPolishRequestRef.current.sequence + 1;
inputPolishRequestRef.current = {
controller,
sequence,
};
setPolishingInput(true);
try {
const result = await polishInputDraft(
{
text: originalText,
locale,
thread_id: threadId,
},
{ signal: controller.signal },
);
const isCurrentRequest =
inputPolishRequestRef.current.controller === controller &&
inputPolishRequestRef.current.sequence === sequence &&
!controller.signal.aborted;
if (!isCurrentRequest || (textInput.value ?? "") !== originalText) {
return;
}
const rewrittenText = result.rewritten_text.trim();
if (!rewrittenText || !result.changed) {
toast.info(t.inputBox.inputPolishNoChanges);
return;
}
// Applying the rewrite replaces the draft outside the textarea change
// handler, so clear any in-progress history browse state; otherwise a
// stale index would let the next ArrowDown overwrite the rewrite.
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setPromptHistoryValue(rewrittenText);
setInputPolishUndo({
originalText,
rewrittenText,
});
} catch (error) {
const isCurrentRequest =
inputPolishRequestRef.current.controller === controller &&
inputPolishRequestRef.current.sequence === sequence;
if (isAbortError(error) || !isCurrentRequest) {
return;
}
toast.error(
error instanceof Error ? error.message : t.inputBox.inputPolishFailed,
);
} finally {
if (
inputPolishRequestRef.current.controller === controller &&
inputPolishRequestRef.current.sequence === sequence
) {
inputPolishRequestRef.current.controller = null;
setPolishingInput(false);
}
}
}, [
inputPolishDisabled,
locale,
setPromptHistoryValue,
t.inputBox.inputPolishFailed,
t.inputBox.inputPolishNoChanges,
textInput,
threadId,
]);
const handleUndoInputPolish = useCallback(() => {
if (!inputPolishUndoAvailable || inputPolishUndo === null) {
return;
}
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setPromptHistoryValue(inputPolishUndo.originalText);
setInputPolishUndo(null);
}, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]);
const handlePromptHistoryKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
isIMEComposing(event) ||
selectedSlashSkill ||
promptHistory.length === 0 ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
) {
return;
}
const currentValue = textInput.value ?? "";
const currentHistoryIndex = promptHistoryIndexRef.current;
const isBrowsingHistory = currentHistoryIndex !== null;
if (!isBrowsingHistory && currentValue.length > 0) {
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const nextIndex = isBrowsingHistory
? Math.max(currentHistoryIndex - 1, 0)
: promptHistory.length - 1;
if (!isBrowsingHistory) {
promptHistoryDraftRef.current = currentValue;
}
promptHistoryIndexRef.current = nextIndex;
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
return;
}
if (!isBrowsingHistory) {
return;
}
event.preventDefault();
if (currentHistoryIndex >= promptHistory.length - 1) {
promptHistoryIndexRef.current = null;
setPromptHistoryValue(promptHistoryDraftRef.current);
promptHistoryDraftRef.current = "";
return;
}
const nextIndex = currentHistoryIndex + 1;
promptHistoryIndexRef.current = nextIndex;
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
},
[promptHistory, selectedSlashSkill, setPromptHistoryValue, textInput.value],
);
const handleSelectedSlashSkillKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (
event.key !== "Backspace" ||
!selectedSlashSkill ||
textInput.value.length > 0 ||
isIMEComposing(event)
) {
return;
}
event.preventDefault();
setSelectedSlashSkill(null);
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
},
[selectedSlashSkill, textInput.value],
);
const handlePromptTextareaKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
// Same rule as the inline-skill editor: the catalog's navigation keys must
// win over Enter-to-submit, but not mid-composition, where Enter belongs
// to the IME candidate rather than the list. Safari's confirming Enter
// carries neither composition flag; PromptInputTextarea drops it before
// this handler runs.
if (!isIMEComposing(event)) {
handleSkillSuggestionKeyDown(event);
if (event.defaultPrevented) {
return;
}
}
handleSelectedSlashSkillKeyDown(event);
if (event.defaultPrevented) {
return;
}
handlePromptHistoryKeyDown(event);
},
[
handlePromptHistoryKeyDown,
handleSelectedSlashSkillKeyDown,
handleSkillSuggestionKeyDown,
],
);
const handlePromptTextareaChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
if (voiceListening) {
abortVoiceInput();
}
abortInputPolishRequest();
setInputPolishUndo(null);
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
scheduleDraftSave({
text: event.currentTarget.value,
skillName:
selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null,
});
},
[
abortInputPolishRequest,
abortVoiceInput,
scheduleDraftSave,
selectedSlashSkill,
voiceListening,
],
);
const updateInlineSkillTextInput = useCallback(
(element: HTMLElement) => {
if (voiceListening) {
abortVoiceInput();
}
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
const nextText = element.textContent ?? "";
textInput.setInput(nextText);
scheduleDraftSave({
text: nextText,
skillName:
selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null,
});
},
[
abortVoiceInput,
scheduleDraftSave,
selectedSlashSkill,
textInput,
voiceListening,
],
);
useEffect(() => {
if (!selectedSlashSkill) {
return;
}
const element = inlineSkillTextRef.current;
if (element && element.textContent !== textInput.value) {
element.textContent = textInput.value;
}
}, [selectedSlashSkill, textInput.value]);
const handleInlineSkillInput = useCallback(
(event: FormEvent<HTMLSpanElement>) => {
updateInlineSkillTextInput(event.currentTarget);
},
[updateInlineSkillTextInput],
);
const handleInlineSkillPaste = useCallback(
(event: ClipboardEvent<HTMLSpanElement>) => {
const pastedFiles = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file")
.flatMap((item) => {
const file = item.getAsFile();
return file ? [file] : [];
});
if (pastedFiles.length > 0) {
event.preventDefault();
const { accepted, message } = splitUnsupportedUploadFiles(pastedFiles);
if (message) {
toast.error(message);
}
if (accepted.length > 0) {
attachments.add(accepted);
}
return;
}
const text = event.clipboardData.getData("text/plain");
if (!text) {
return;
}
event.preventDefault();
if (insertPlainTextAtSelection(event.currentTarget, text)) {
updateInlineSkillTextInput(event.currentTarget);
}
},
[attachments, updateInlineSkillTextInput],
);
const handleInlineSkillKeyDown = useCallback(
(event: KeyboardEvent<HTMLSpanElement>) => {
// The catalog can be reopened from here, so its navigation keys must win
// over Enter-to-submit. Skip it mid-composition, where Enter belongs to
// the IME candidate rather than the list.
if (!isIMEComposing(event, inlineSkillComposingRef.current)) {
handleSkillSuggestionKeyDown(event);
if (event.defaultPrevented) {
return;
}
}
handleSelectedSlashSkillKeyDown(event);
if (event.defaultPrevented) {
return;
}
if (event.key !== "Enter") {
return;
}
if (isIMEComposing(event, inlineSkillComposingRef.current)) {
return;
}
event.preventDefault();
if (event.shiftKey) {
if (insertPlainTextAtSelection(event.currentTarget, "\n")) {
updateInlineSkillTextInput(event.currentTarget);
}
return;
}
event.currentTarget.closest("form")?.requestSubmit();
},
[
handleSelectedSlashSkillKeyDown,
handleSkillSuggestionKeyDown,
updateInlineSkillTextInput,
],
);
const clearSelectedSlashSkill = useCallback(() => {
setSelectedSlashSkill(null);
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}, []);
const showFollowups =
!disabled &&
!isWelcomeMode &&
!showSkillSuggestions &&
!selectedSlashSkill &&
!followupsHidden &&
// Never show stale follow-up chips while a turn is streaming: a message
// sent before the previous response finished would otherwise leave the
// old chips (and the lone close button) overlapping the input box.
status !== "streaming" &&
(followupsLoading || followups.length > 0);
useEffect(() => {
onFollowupsVisibilityChange?.(showFollowups);
}, [onFollowupsVisibilityChange, showFollowups]);
useEffect(() => {
return () => onFollowupsVisibilityChange?.(false);
}, [onFollowupsVisibilityChange]);
useEffect(() => {
messagesRef.current = thread.messages;
}, [thread.messages]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
return;
}
// The turn was interrupted by the user, so skip generating follow-ups for
// this half-finished response.
if (stoppedByUserRef.current) {
stoppedByUserRef.current = false;
return;
}
if (disabled || isMock) {
return;
}
const lastAi = [...messagesRef.current]
.reverse()
.find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
if (!suggestionsConfigLoaded) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = messagesRef.current
.filter((m) => m.type === "human" || m.type === "ai")
.filter((m) => !isHiddenFromUIMessage(m))
.map((m) => {
const role = m.type === "human" ? "user" : "assistant";
const content = textOfMessage(m) ?? "";
return { role, content };
})
.filter((m) => m.content.trim().length > 0)
.slice(-6);
if (recent.length === 0) {
return;
}
if (!suggestionsEnabled) {
setFollowups([]);
return;
}
const controller = new AbortController();
setFollowupsHidden(false);
setFollowupsLoading(true);
setFollowups([]);
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: recent,
n: maxFollowupSuggestions,
model_name: context.model_name ?? undefined,
}),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
return { suggestions: [] as string[] };
}
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
.slice(0, maxFollowupSuggestions);
setFollowups(suggestions);
})
.catch(() => {
setFollowups([]);
})
.finally(() => {
setFollowupsLoading(false);
});
return () => controller.abort();
}, [
context.model_name,
disabled,
isMock,
maxFollowupSuggestions,
status,
suggestionsConfigLoaded,
suggestionsEnabled,
threadId,
]);
const onSelectPlaceholder = useCallback((newText: string) => {
const placeholder = findSuggestionTemplatePlaceholder(newText);
if (placeholder) {
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(placeholder.start, placeholder.end);
});
}
}, []);
return (
<div
ref={promptRootRef}
className={cn(
"relative flex min-w-0 flex-col",
isWelcomeMode ? "gap-4" : "gap-2",
)}
>
{showFollowups && (
<div className="flex items-center justify-center pb-1">
<div className="flex items-center gap-2">
{followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-1.5 text-xs backdrop-blur-sm">
{t.inputBox.followupLoading}
</div>
) : (
<Suggestions className="w-fit items-center">
{followups.map((s) => (
<Suggestion
key={s}
className="py-1.5"
suggestion={s}
onClick={() => handleFollowupClick(s)}
/>
))}
<Button
aria-label={t.common.close}
className="text-muted-foreground h-auto cursor-pointer rounded-full px-2.5 py-1.5 text-xs font-normal"
variant="outline"
size="sm"
type="button"
onClick={() => setFollowupsHidden(true)}
>
<XIcon className="size-4" />
</Button>
</Suggestions>
)}
</div>
</div>
)}
{showSkillSuggestions && (
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
<div
aria-label="Skill suggestions"
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
role="listbox"
>
{skillSuggestions.map((suggestion, index) => {
const selected = index === skillSuggestionIndex;
return (
<button
aria-selected={selected}
className={cn(
"flex min-h-12 w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors",
selected
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
)}
key={`${suggestion.kind}:${suggestion.name}`}
onClick={() => applySkillSuggestion(suggestion)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSkillSuggestionIndex(index)}
role="option"
type="button"
>
{suggestion.kind === "builtin" ? (
<TargetIcon className="text-muted-foreground size-4 shrink-0" />
) : (
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
/{suggestion.name}
</span>
{suggestion.description && (
<span className="text-muted-foreground block truncate text-xs">
{suggestion.description}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
)}
<PromptInput
className={cn(
"bg-background/85 relative z-10 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
polishingInput &&
"shadow-primary/10 ring-primary/25 shadow-lg ring-1",
className,
)}
disabled={composerLocked}
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
>
{polishingInput && (
<div
aria-hidden="true"
className="border-primary/30 bg-primary/5 pointer-events-auto absolute inset-0 z-20 animate-pulse cursor-wait rounded-2xl border opacity-80"
/>
)}
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
{extraHeader}
</div>
</div>
)}
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
<PromptInputAttachments className="contents p-0">
{(attachment) => (
<div className="max-w-60">
<PromptInputAttachment data={attachment} />
</div>
)}
</PromptInputAttachments>
{projectAttachments.map((attachment) => (
<div
key={attachment.virtual_path}
className="bg-muted text-muted-foreground flex h-7 items-center gap-1.5 rounded-full border py-0 pr-1 pl-2.5 text-xs font-medium"
data-testid="project-attachment-chip"
>
<PaperclipIcon className="size-3" />
<span className="max-w-40 truncate">{attachment.filename}</span>
<button
aria-label={t.inputBox.removeProjectAttachment}
className="hover:bg-primary/20 focus-visible:ring-primary/40 -mr-0.5 ml-0.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
type="button"
onClick={() =>
setProjectAttachments((previous) =>
previous.filter(
(candidate) =>
candidate.virtual_path !== attachment.virtual_path,
),
)
}
>
<XIcon className="size-3" />
</button>
</div>
))}
{conversationReferences.map((reference) => (
<ConversationReferenceChip
key={reference.threadId}
onRemove={() =>
setConversationReferences((current) =>
current.filter(
(item) => item.threadId !== reference.threadId,
),
)
}
removeLabel={t.inputBox.referenceConversationsRemove(
reference.title,
)}
title={reference.title}
/>
))}
{polishingInput && (
<div
aria-live="polite"
className="text-primary bg-primary/10 border-primary/20 relative z-30 flex h-7 items-center gap-1.5 rounded-full border py-0 pr-1 pl-2.5 text-xs font-medium"
role="status"
>
<Loader2Icon className="size-3 animate-spin" />
{t.inputBox.inputPolishing}
<button
aria-label={t.inputBox.inputPolishCancel}
className="hover:bg-primary/20 focus-visible:ring-primary/40 -mr-0.5 ml-0.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
data-testid="cancel-polish-input-button"
onClick={abortInputPolishRequest}
type="button"
>
<XIcon className="size-3" />
</button>
</div>
)}
{sidecar && sidecar.conversationQuotes.length > 0 && (
<ReferenceAttachmentSummary
references={sidecar.conversationQuotes}
testId="conversation-quote-attachment"
onClear={() => sidecar.clearConversationQuotes()}
/>
)}
</PromptInputHeader>
<div className="min-h-16 w-full min-w-0 px-3 py-3">
{selectedSlashSkill ? (
<div
className="max-h-48 min-h-6 w-full min-w-0 cursor-text overflow-y-auto text-base leading-6 break-all whitespace-pre-wrap md:text-sm"
onClick={(event) => {
if (event.target === event.currentTarget) {
focusContentEditableEnd(inlineSkillTextRef.current);
}
}}
>
<SlashSkillChip
name={selectedSlashSkill.name}
className="mr-2 max-w-[min(11rem,45%)] align-top"
onRemove={clearSelectedSlashSkill}
/>
<span
aria-label={t.inputBox.placeholder}
aria-multiline="true"
contentEditable={!composerLocked}
data-empty={textInput.value.length === 0}
data-placeholder={t.inputBox.placeholder}
data-slot="input-group-control"
onBlur={() => setTextareaFocused(false)}
onCompositionEnd={() => {
inlineSkillComposingRef.current = false;
}}
onCompositionStart={() => {
inlineSkillComposingRef.current = true;
}}
onFocus={() => setTextareaFocused(true)}
onInput={handleInlineSkillInput}
onKeyDown={handleInlineSkillKeyDown}
onPaste={handleInlineSkillPaste}
aria-placeholder={t.inputBox.placeholder}
ref={inlineSkillTextRef}
role="textbox"
suppressContentEditableWarning
className={cn(
"outline-none",
"before:text-muted-foreground before:pointer-events-none",
"data-[empty=true]:before:content-[attr(data-placeholder)]",
composerLocked && "cursor-not-allowed opacity-50",
)}
tabIndex={composerLocked ? -1 : 0}
/>
</div>
) : (
<PromptInputTextarea
className="min-h-6! w-full min-w-0 p-0! leading-6!"
disabled={composerLocked}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
onBlur={() => setTextareaFocused(false)}
onChange={handlePromptTextareaChange}
onFocus={() => setTextareaFocused(true)}
onKeyDown={handlePromptTextareaKeyDown}
ref={textareaRef}
/>
)}
</div>
<PromptInputFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
<PromptInputTools className="min-w-0 flex-1 flex-wrap">
<AddAttachmentsButton
className="px-2!"
disabled={composerLocked}
uploadLimits={uploadLimits}
/>
<ReferenceConversationsButton
className="px-2!"
currentThreadId={threadId}
disabled={composerLocked}
onChange={setConversationReferences}
references={conversationReferences}
/>
<VoiceInputButton
disabled={composerLocked}
listening={voiceListening}
supported={voiceInputSupported}
onToggle={toggleVoiceInput}
/>
<Tooltip
content={
polishingInput
? t.inputBox.inputPolishing
: inputPolishUndoAvailable
? t.inputBox.inputPolishUndo
: t.inputBox.inputPolish
}
>
<PromptInputButton
aria-label={
inputPolishUndoAvailable
? t.inputBox.inputPolishUndo
: t.inputBox.inputPolish
}
className="px-2!"
data-testid="polish-input-button"
disabled={inputPolishDisabled}
onClick={
inputPolishUndoAvailable
? handleUndoInputPolish
: handlePolishInput
}
>
{polishingInput ? (
<Loader2Icon className="size-3 animate-spin" />
) : inputPolishUndoAvailable ? (
<Undo2Icon className="size-3" />
) : (
<SparklesIcon className="size-3" />
)}
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
context.mode === "flash" ||
context.mode === "thinking" ||
context.mode === "pro" ||
context.mode === "ultra"
? context.mode
: "flash"
}
>
<PromptInputActionMenuTrigger
className="max-w-28 gap-1! px-2! sm:max-w-none"
disabled={composerLocked}
>
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
<LightbulbIcon className="size-3" />
)}
{context.mode === "pro" && (
<GraduationCapIcon className="size-3" />
)}
{context.mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
)}
</div>
<div
className={cn(
"truncate text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{(context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" &&
t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
</ModeHoverGuide>
<PromptInputActionMenuContent className="w-80">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
{!thinkingRequired && (
<PromptInputActionMenuItem
className={cn(
context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
context.mode === "flash" &&
"text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
{context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("thinking")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<LightbulbIcon
className={cn(
"mr-2 size-4",
context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
{t.inputBox.reasoningMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.reasoningModeDescription}
</div>
</div>
{context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("pro")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GraduationCapIcon
className={cn(
"mr-2 size-4",
context.mode === "pro" &&
"text-accent-foreground",
)}
/>
{t.inputBox.proMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.proModeDescription}
</div>
</div>
{context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("ultra")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<RocketIcon
className={cn(
"mr-2 size-4",
context.mode === "ultra" && "text-[#dabb5e]",
)}
/>
<div
className={cn(
context.mode === "ultra" && "golden-text",
)}
>
{t.inputBox.ultraMode}
</div>
</div>
<div className="pl-7 text-xs">
{t.inputBox.ultraModeDescription}
</div>
</div>
{context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{knowledgeScopeControl}
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger
className="hidden gap-1! px-2! sm:inline-flex"
disabled={composerLocked}
>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:{" "}
{reasoningEffortLabel(effectiveReasoningEffort)}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
{reasoningEffortOptions.map((effort) => (
<PromptInputActionMenuItem
key={effort}
className={cn(
effectiveReasoningEffort === effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect(effort)}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{reasoningEffortLabel(effort)}
</div>
{reasoningEffortDescription(effort) && (
<div className="pl-2 text-xs">
{reasoningEffortDescription(effort)}
</div>
)}
</div>
{effectiveReasoningEffort === effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
))}
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className="min-w-0 justify-end">
{goalObjectiveCounter && (
<span
aria-label={t.inputBox.goalLengthCounter
.replace("{length}", () =>
String(goalObjectiveCounter.length),
)
.replace("{max}", () => String(goalObjectiveCounter.max))}
className={cn(
"shrink-0 text-xs tabular-nums",
goalObjectiveCounter.overLimit
? "text-destructive font-medium"
: "text-muted-foreground",
)}
data-testid="goal-length-counter"
>
{goalObjectiveCounter.length}/{goalObjectiveCounter.max}
</span>
)}
<ModelPicker
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
<ModelPickerTrigger asChild>
<PromptInputButton
className="max-w-40 min-w-0 sm:max-w-56"
disabled={composerLocked}
>
<div className="flex min-w-0 flex-col text-left">
<span className="flex-1 truncate text-left text-xs font-normal">
{selectedModel?.display_name}
</span>
</div>
</PromptInputButton>
</ModelPickerTrigger>
<ModelPickerContent
open={modelDialogOpen}
models={models}
selectedModelName={selectedModel?.name}
onModelSelect={handleModelSelect}
/>
</ModelPicker>
<PromptInputSubmit
className="rounded-full"
disabled={composerLocked || stopDenied || sendDenied}
variant="outline"
status={status}
// A bare disabled square reads as a broken composer; explain
// the permission boundary (native title, since a Radix
// tooltip won't fire on a disabled button). Spread
// conditionally: an explicitly-undefined aria-label would
// clobber PromptInputSubmit's default aria-label="Submit"
// and strip the submit control's accessible name.
{...(stopDenied
? {
"aria-label": t.inputBox.stopStreamingUnavailable,
title: t.inputBox.stopStreamingUnavailable,
}
: sendDenied
? {
"aria-label": t.inputBox.startTurnUnavailable,
title: t.inputBox.startTurnUnavailable,
}
: {})}
onClick={(e) => {
if (status === "streaming") {
e.preventDefault();
handleStopStreaming();
}
}}
/>
</PromptInputTools>
</PromptInputFooter>
</PromptInput>
{!isWelcomeMode && (
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div>
)}
{isWelcomeMode &&
searchParams.get("mode") !== "skill" &&
!selectedSlashSkill &&
!showSkillSuggestions && (
<div className="flex items-center justify-center pt-2">
<SuggestionList onSelectPlaceholder={onSelectPlaceholder} />
</div>
)}
<p
className={cn(
"text-muted-foreground/67 z-10 px-4 text-center text-xs leading-4",
!isWelcomeMode && "absolute top-full right-0 left-0",
)}
>
{t.inputBox.disclaimer}
</p>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.inputBox.followupConfirmTitle}</DialogTitle>
<DialogDescription>
{t.inputBox.followupConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t.common.cancel}
</Button>
<Button variant="secondary" onClick={confirmAppendAndSend}>
{t.inputBox.followupConfirmAppend}
</Button>
<Button onClick={confirmReplaceAndSend}>
{t.inputBox.followupConfirmReplace}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function VoiceInputButton({
disabled,
listening,
supported,
onToggle,
}: {
disabled?: boolean;
listening: boolean;
supported: boolean;
onToggle: () => void;
}) {
const { t } = useI18n();
const tooltipContent = !supported
? t.inputBox.voiceInputUnsupported
: listening
? t.inputBox.voiceInputListening
: t.inputBox.voiceInputStart;
const label = listening
? t.inputBox.voiceInputStopLabel
: t.inputBox.voiceInputStartLabel;
return (
<Tooltip content={<span className="block max-w-72">{tooltipContent}</span>}>
<PromptInputButton
aria-label={label}
aria-pressed={listening}
className={cn(
"px-2!",
listening && "text-primary bg-primary/10 hover:bg-primary/15",
)}
data-testid="voice-input-button"
disabled={(disabled ?? false) || !supported}
onClick={onToggle}
>
{listening ? (
<SquareIcon className="size-3 fill-current" />
) : (
<MicIcon className="size-3" />
)}
</PromptInputButton>
</Tooltip>
);
}
function SuggestionList({
onSelectPlaceholder,
}: {
onSelectPlaceholder: (newText: string) => void;
}) {
const { t } = useI18n();
const { textInput } = usePromptInputController();
const handleSuggestionClick = useCallback(
(prompt: string | undefined) => {
if (!prompt) return;
textInput.setInput(prompt);
onSelectPlaceholder(prompt);
},
[textInput, onSelectPlaceholder],
);
return (
<Suggestions className="min-h-16 w-full max-w-full justify-center px-4 sm:w-fit sm:px-0">
<ConfettiButton
className="text-muted-foreground cursor-pointer rounded-full px-4 text-xs font-normal"
variant="outline"
size="sm"
onClick={() => handleSuggestionClick(t.inputBox.surpriseMePrompt)}
>
<SparklesIcon className="size-4" /> {t.inputBox.surpriseMe}
</ConfettiButton>
{t.inputBox.suggestions.map((suggestion) => (
<Suggestion
key={suggestion.suggestion}
icon={suggestion.icon}
suggestion={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion.prompt)}
/>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Suggestion icon={PlusIcon} suggestion={t.common.create} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
{t.inputBox.suggestionsCreate.map((suggestion, index) =>
"type" in suggestion && suggestion.type === "separator" ? (
<DropdownMenuSeparator key={index} />
) : (
!("type" in suggestion) && (
<DropdownMenuItem
key={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion.prompt)}
>
{suggestion.icon && <suggestion.icon className="size-4" />}
{suggestion.suggestion}
</DropdownMenuItem>
)
),
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</Suggestions>
);
}
function AddAttachmentsButton({
className,
disabled,
uploadLimits,
}: {
className?: string;
disabled?: boolean;
uploadLimits?: UploadLimits;
}) {
const { t } = useI18n();
const attachments = usePromptInputAttachments();
const tooltipContent = uploadLimits
? t.uploads.limitsHint(
uploadLimits.max_files,
formatUploadSize(uploadLimits.max_file_size),
formatUploadSize(uploadLimits.max_total_size),
)
: t.inputBox.addAttachments;
return (
<Tooltip content={<span className="block max-w-80">{tooltipContent}</span>}>
<PromptInputButton
aria-label={t.inputBox.addAttachments}
className={cn("px-2!", className)}
data-testid="add-attachments-button"
disabled={disabled}
onClick={() => attachments.openFileDialog()}
>
<PaperclipIcon className="size-3" />
</PromptInputButton>
</Tooltip>
);
}