mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 14:10:37 +00:00
* 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.
3119 lines
102 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|