mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-13 06:31:47 +00:00
* feat(frontend): support extension providers in composer mentions * fix(mentions): retain settled plugin suggestions during search * fix(extensions): validate identifiers and skip empty mention searches * test: stabilize history pagination after latest page refresh --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
3718 lines
123 KiB
TypeScript
3718 lines
123 KiB
TypeScript
"use client";
|
|
|
|
import type { Message } from "@langchain/langgraph-sdk";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { ChatStatus } from "ai";
|
|
import {
|
|
AtSignIcon,
|
|
CheckIcon,
|
|
GraduationCapIcon,
|
|
LightbulbIcon,
|
|
Loader2Icon,
|
|
MicIcon,
|
|
PaperclipIcon,
|
|
PlusIcon,
|
|
RocketIcon,
|
|
SparklesIcon,
|
|
SquareIcon,
|
|
TargetIcon,
|
|
Undo2Icon,
|
|
XIcon,
|
|
ZapIcon,
|
|
} from "lucide-react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import {
|
|
useCallback,
|
|
useId,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ComponentProps,
|
|
type ClipboardEvent,
|
|
type FormEvent,
|
|
type KeyboardEvent,
|
|
} from "react";
|
|
import { flushSync } from "react-dom";
|
|
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 {
|
|
extensionMentionId,
|
|
MAX_EXTENSION_MENTIONS,
|
|
} from "@/core/extensions/mentions";
|
|
import { useConversationReferencesCapability } from "@/core/features/hooks";
|
|
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 { attachProjectDocument } from "@/core/projects/api";
|
|
import { useStagedProjectAttachments } from "@/core/projects/composer-attach";
|
|
import {
|
|
buildReferenceMessageMetadata,
|
|
type SidecarContext,
|
|
} from "@/core/sidecar";
|
|
import { parseSlashSkillReference } from "@/core/skills";
|
|
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 { isCompositionConfirmEnter, 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 {
|
|
abortGoalRequest,
|
|
beginGoalRequest,
|
|
canPolishInput,
|
|
createGoalRequestState,
|
|
findSuggestionTemplatePlaceholder,
|
|
finishGoalRequest,
|
|
filterSkillsForAgent,
|
|
getGoalObjectiveCounter,
|
|
getInputSubmitAction,
|
|
getLeadingSlashCommandQuery,
|
|
getMatchingSlashCommands,
|
|
type GoalCommand,
|
|
isAbortError,
|
|
isCurrentGoalRequest,
|
|
isGoalObjectiveTooLong,
|
|
MAX_GOAL_OBJECTIVE_CHARS,
|
|
readGoalResponseError,
|
|
type SlashCommandSuggestion,
|
|
} from "./input-box-helpers";
|
|
import {
|
|
extensionMentionMetadata,
|
|
inlineReferences,
|
|
reconcileConversationReferences,
|
|
MAX_EXPLICIT_SKILLS,
|
|
referenceToken,
|
|
readableReferences,
|
|
restoreReferenceLabels,
|
|
readReferenceEditor,
|
|
referenceCaret,
|
|
focusReferenceAt,
|
|
renderReferenceEditor,
|
|
selectReferenceForDeletion,
|
|
} from "./mentions/inline-references";
|
|
import {
|
|
MentionPicker,
|
|
type MentionPickerHandle,
|
|
type MentionSelection,
|
|
} from "./mentions/mention-picker";
|
|
import { getMentionQuery, type MentionQuery } from "./mentions/query";
|
|
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 { 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,
|
|
projectId,
|
|
draftAgentName,
|
|
defaultModelName,
|
|
knowledgeScopeControl,
|
|
initialValue,
|
|
onContextChange,
|
|
onFollowupsVisibilityChange,
|
|
onGoalChange,
|
|
onPrepareThread,
|
|
onReferenceFileAttached,
|
|
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;
|
|
projectId?: string | null;
|
|
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>;
|
|
/** Move a prepared new project chat to its durable URL after file ingestion. */
|
|
onReferenceFileAttached?: () => 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 mentionListId = useId();
|
|
const mentionPickerRef = useRef<MentionPickerHandle>(null);
|
|
const [activeMentionOption, setActiveMentionOption] = useState<
|
|
string | undefined
|
|
>();
|
|
const conversationCapability = useConversationReferencesCapability();
|
|
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null);
|
|
const [mentionButtonOpen, setMentionButtonOpen] = useState(false);
|
|
const [mentionBusy, setMentionBusy] = useState(false);
|
|
const [mentionPlacement, setMentionPlacement] = useState({
|
|
above: true,
|
|
maxHeight: 400,
|
|
});
|
|
const [mentionError, setMentionError] = useState<string | null>(null);
|
|
const mentionEpoch = useRef(0);
|
|
const mentionInFlight = useRef(false);
|
|
const dismissedMention = useRef<string | null>(null);
|
|
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;
|
|
// References belong to the draft and clear only when its send proceeds.
|
|
const [conversationReferences, setConversationReferences] = useState<
|
|
ConversationReference[]
|
|
>([]);
|
|
|
|
useLayoutEffect(() => {
|
|
mentionEpoch.current += 1;
|
|
mentionInFlight.current = false;
|
|
setMentionBusy(false);
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
setMentionError(null);
|
|
return () => {
|
|
mentionEpoch.current += 1;
|
|
};
|
|
}, [threadId, projectId]);
|
|
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,
|
|
isFetching: skillsFetching,
|
|
error: skillsError,
|
|
refetch: refetchSkills,
|
|
} = 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 composerCaretRef = useRef(0);
|
|
const inlineRefs = useMemo(
|
|
() => inlineReferences(textInput.value),
|
|
[textInput.value],
|
|
);
|
|
const hasInlineReferences = inlineRefs.length > 0;
|
|
const conversationReferencesUnavailable =
|
|
(!conversationCapability.isSuccess || conversationCapability.isLoading) &&
|
|
inlineRefs.some((ref) => ref.kind === "conversation");
|
|
const reconciledConversations = useMemo(
|
|
() =>
|
|
reconcileConversationReferences(
|
|
textInput.value,
|
|
conversationReferences,
|
|
{
|
|
enabled: conversationCapability.enabled,
|
|
maxReferences: conversationCapability.maxReferences,
|
|
isLoading: conversationCapability.isLoading,
|
|
isSuccess: conversationCapability.isSuccess,
|
|
},
|
|
threadId,
|
|
),
|
|
[
|
|
textInput.value,
|
|
conversationReferences,
|
|
conversationCapability.enabled,
|
|
conversationCapability.maxReferences,
|
|
conversationCapability.isLoading,
|
|
conversationCapability.isSuccess,
|
|
threadId,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
if (reconciledConversations.text !== textInput.value)
|
|
setTextInput(reconciledConversations.text);
|
|
}, [reconciledConversations.text, textInput.value, setTextInput]);
|
|
const [inlineEditorActive, setInlineEditorActive] = useState(false);
|
|
const projectReferenceCache = useRef(
|
|
new Map<string, (typeof projectAttachments)[number]>(),
|
|
);
|
|
const conversationReferenceCache = useRef(
|
|
new Map<string, ConversationReference>(),
|
|
);
|
|
useLayoutEffect(() => {
|
|
projectReferenceCache.current.clear();
|
|
setInlineEditorActive(false);
|
|
}, [threadId, user?.id]);
|
|
useLayoutEffect(() => {
|
|
for (const attachment of projectAttachments) {
|
|
if (attachment.source_document_id)
|
|
projectReferenceCache.current.set(
|
|
attachment.source_document_id,
|
|
attachment,
|
|
);
|
|
}
|
|
}, [projectAttachments]);
|
|
useLayoutEffect(() => {
|
|
for (const reference of conversationReferences)
|
|
conversationReferenceCache.current.set(reference.threadId, reference);
|
|
}, [conversationReferences]);
|
|
const inlineCompositionEndedAt = useRef(-Infinity);
|
|
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 pendingDraftSubmissionRef = useRef<{
|
|
key: string;
|
|
text: string;
|
|
skillName: string | null;
|
|
} | null>(null);
|
|
const acceptedDraftRef = useRef<{
|
|
key: string;
|
|
text: string;
|
|
skillName: string | null;
|
|
} | null>(null);
|
|
const latestDraftRef = useRef<{
|
|
key: string;
|
|
draft: ComposerDraft;
|
|
} | 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 [commandSuggestionIndex, setCommandSuggestionIndex] = useState(0);
|
|
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
|
const [dismissedCommandSuggestionValue, setDismissedCommandSuggestionValue] =
|
|
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<SlashCommandSuggestion[]>(
|
|
() => [
|
|
{
|
|
name: "goal",
|
|
description: t.inputBox.goalCommandDescription,
|
|
},
|
|
{
|
|
name: "compact",
|
|
description: t.inputBox.compactCommandDescription,
|
|
},
|
|
],
|
|
[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 mentionSkills = useMemo(
|
|
() =>
|
|
agentScopedSkills.filter(
|
|
(skill) =>
|
|
parseSlashSkillReference(`/${skill.name}`)?.name === skill.name,
|
|
),
|
|
[agentScopedSkills],
|
|
);
|
|
const enabledSkillNames = useMemo(
|
|
() =>
|
|
new Set(
|
|
mentionSkills
|
|
.filter((skill) => skill.enabled)
|
|
.map((skill) => skill.name),
|
|
),
|
|
[mentionSkills],
|
|
);
|
|
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) => {
|
|
// An accepted attachment send keeps its text visible until upload finishes.
|
|
// Reference cleanup can rerun this effect meanwhile; do not resurrect the
|
|
// accepted snapshot. Actual input edits release it, even for identical text.
|
|
const accepted = acceptedDraftRef.current;
|
|
if (
|
|
accepted?.key === key &&
|
|
accepted.text === draft.text &&
|
|
accepted.skillName === draft.skillName
|
|
)
|
|
return null;
|
|
if (accepted?.key === key && !draft.text && !draft.skillName) {
|
|
acceptedDraftRef.current = null;
|
|
}
|
|
if (
|
|
!draft.text &&
|
|
!draft.skillName &&
|
|
pendingDraftSubmissionRef.current?.key === key
|
|
) {
|
|
return null;
|
|
}
|
|
const pending = pendingDraftSubmissionRef.current;
|
|
if (
|
|
pending?.key === key &&
|
|
(draft.text !== pending.text || draft.skillName !== pending.skillName)
|
|
) {
|
|
pendingDraftSubmissionRef.current = null;
|
|
}
|
|
|
|
const reconciled = reconcileConversationReferences(
|
|
draft.text,
|
|
conversationReferences,
|
|
conversationCapability,
|
|
threadId,
|
|
);
|
|
draft = {
|
|
...draft,
|
|
text: reconciled.text,
|
|
conversationReferences: reconciled.references,
|
|
};
|
|
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,
|
|
conversationReferences,
|
|
conversationCapability,
|
|
threadId,
|
|
],
|
|
);
|
|
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("");
|
|
conversationReferenceCache.current.clear();
|
|
setConversationReferences([]);
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
setMentionError(null);
|
|
setMentionBusy(false);
|
|
mentionInFlight.current = false;
|
|
mentionEpoch.current += 1;
|
|
setInputPolishUndo(null);
|
|
setHydratedDraftKey(null);
|
|
pendingDraftSubmissionRef.current = null;
|
|
acceptedDraftRef.current = null;
|
|
latestDraftRef.current = null;
|
|
invalidateDraftSaveTimer();
|
|
return () => {
|
|
mentionEpoch.current += 1;
|
|
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);
|
|
const restoredText =
|
|
resolvedDraft.skillName &&
|
|
!inlineReferences(resolvedDraft.text).some(
|
|
(ref) => ref.kind === "skill" && ref.id === resolvedDraft.skillName,
|
|
)
|
|
? referenceToken(
|
|
"skill",
|
|
resolvedDraft.skillName,
|
|
resolvedDraft.skillName,
|
|
) + (resolvedDraft.text ? ` ${resolvedDraft.text}` : "")
|
|
: resolvedDraft.text;
|
|
setConversationReferences(savedDraft.conversationReferences ?? []);
|
|
const existingReferences = inlineReferences(restoredText);
|
|
const legacyReferences = (savedDraft.conversationReferences ?? []).filter(
|
|
(ref) =>
|
|
!existingReferences.some(
|
|
(item) => item.kind === "conversation" && item.id === ref.threadId,
|
|
),
|
|
);
|
|
setTextInput(
|
|
legacyReferences
|
|
.map(
|
|
(ref) =>
|
|
referenceToken("conversation", ref.threadId, ref.title) + " ",
|
|
)
|
|
.join("") + restoredText,
|
|
);
|
|
setHydratedDraftKey(draftKey);
|
|
}, [
|
|
draftKey,
|
|
enabledSkillNames,
|
|
hydratedDraftKey,
|
|
initialValue,
|
|
setTextInput,
|
|
agentScopedSkills,
|
|
agentSkillsLoading,
|
|
skillsLoading,
|
|
textInput.value,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (hydratedDraftKey !== draftKey) {
|
|
return;
|
|
}
|
|
|
|
const draft: ComposerDraft = {
|
|
text: textInput.value ?? "",
|
|
skillName: null,
|
|
};
|
|
const timer = scheduleDraftSave(draft, draftKey);
|
|
return () => {
|
|
if (timer === null) {
|
|
return;
|
|
}
|
|
window.clearTimeout(timer);
|
|
if (draftSaveTimerRef.current === timer) {
|
|
draftSaveTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [draftKey, hydratedDraftKey, scheduleDraftSave, 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);
|
|
const currentReferences = inlineReferences(textInput.value);
|
|
if (
|
|
(!conversationCapability.isSuccess ||
|
|
conversationCapability.isLoading) &&
|
|
currentReferences.some((ref) => ref.kind === "conversation")
|
|
) {
|
|
return Promise.reject(
|
|
new Error(
|
|
"Conversation capability is not available. Retry discovery before sending.",
|
|
),
|
|
);
|
|
}
|
|
const activeConversations = reconcileConversationReferences(
|
|
textInput.value,
|
|
conversationReferences,
|
|
conversationCapability,
|
|
threadId,
|
|
).references;
|
|
const referenceIds = activeConversations.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
|
|
.filter(
|
|
(file) =>
|
|
!file.source_document_id ||
|
|
currentReferences.some(
|
|
(ref) =>
|
|
ref.kind === "file" && ref.id === file.source_document_id,
|
|
),
|
|
)
|
|
.map((attachment) => ({
|
|
filename: attachment.filename,
|
|
size: attachment.size_bytes,
|
|
path: attachment.virtual_path,
|
|
status: "uploaded" as const,
|
|
}));
|
|
const skillReferences = [
|
|
...new Set(
|
|
inlineReferences(textInput.value)
|
|
.filter((ref) => ref.kind === "skill")
|
|
.map((ref) => ref.id),
|
|
),
|
|
];
|
|
if (skillReferences.length > MAX_EXPLICIT_SKILLS) {
|
|
toast.warning(t.inputBox.mentionMultipleSkills);
|
|
return Promise.reject(new Error("Too many skill references."));
|
|
}
|
|
const extensionMetadata = extensionMentionMetadata(textInput.value);
|
|
if (
|
|
(extensionMetadata.extension_mentions?.length ?? 0) >
|
|
MAX_EXTENSION_MENTIONS
|
|
) {
|
|
toast.warning(t.inputBox.mentionExtensionsLimit);
|
|
return Promise.reject(new Error("Too many extension mentions"));
|
|
}
|
|
pendingDraftSubmissionRef.current = {
|
|
key: draftKey,
|
|
text: textInput.value,
|
|
skillName: null,
|
|
};
|
|
const additionalKwargs = {
|
|
...extensionMetadata,
|
|
...(skillReferences.length
|
|
? { skill_references: skillReferences }
|
|
: {}),
|
|
...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}),
|
|
...(referenceIds.length
|
|
? buildConversationReferenceMetadata(activeConversations)
|
|
: {}),
|
|
...(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: () => {
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
if (pendingDraftSubmissionRef.current?.key === draftKey) {
|
|
acceptedDraftRef.current = {
|
|
key: draftKey,
|
|
text: textInput.value,
|
|
skillName: null,
|
|
};
|
|
pendingDraftSubmissionRef.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,
|
|
textInput.value,
|
|
conversationReferences,
|
|
draftKey,
|
|
invalidateDraftSaveTimer,
|
|
onContextChange,
|
|
onSubmit,
|
|
projectAttachments,
|
|
setProjectAttachments,
|
|
reportUploadLimitViolations,
|
|
resolvedModelName,
|
|
selectedModel,
|
|
sidecar,
|
|
t.inputBox.suggestionPlaceholderRequired,
|
|
t.inputBox.mentionMultipleSkills,
|
|
t.inputBox.mentionExtensionsLimit,
|
|
conversationCapability,
|
|
threadId,
|
|
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 (mentionInFlight.current)
|
|
return Promise.reject(new Error("Reference is loading"));
|
|
if (status === "streaming") {
|
|
toast.info(t.inputBox.pleaseWaitStreaming);
|
|
return Promise.reject(new Error("streaming"));
|
|
}
|
|
abortVoiceInput();
|
|
message = { ...message, text: readableReferences(message.text) };
|
|
const submitAction = getInputSubmitAction({
|
|
text: message.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 or explicit conversation reference is present.
|
|
fileCount:
|
|
message.files.length +
|
|
projectAttachments.length +
|
|
conversationReferences.length +
|
|
inlineRefs.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(message);
|
|
},
|
|
[
|
|
abortVoiceInput,
|
|
canCreateRuns,
|
|
handleCompactCommand,
|
|
handleGoalCommand,
|
|
onPrepareThread,
|
|
projectAttachments.length,
|
|
conversationReferences.length,
|
|
inlineRefs.length,
|
|
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 slashCommandQuery = useMemo(
|
|
() => getLeadingSlashCommandQuery(textInput.value ?? ""),
|
|
[textInput.value],
|
|
);
|
|
const goalObjectiveCounter = useMemo(
|
|
() => getGoalObjectiveCounter(textInput.value ?? ""),
|
|
[textInput.value],
|
|
);
|
|
const commandSuggestions = useMemo(
|
|
() =>
|
|
slashCommandQuery === null || hasInlineReferences
|
|
? []
|
|
: getMatchingSlashCommands(slashCommandQuery, builtinSlashCommands),
|
|
[builtinSlashCommands, hasInlineReferences, slashCommandQuery],
|
|
);
|
|
const showCommandSuggestions =
|
|
!disabled &&
|
|
textareaFocused &&
|
|
slashCommandQuery !== null &&
|
|
commandSuggestions.length > 0 &&
|
|
dismissedCommandSuggestionValue !== textInput.value;
|
|
const isComposerDisabled = disabled === true;
|
|
const isMockThread = isMock === true;
|
|
const composerLocked = isComposerDisabled || polishingInput || mentionBusy;
|
|
const showMentions =
|
|
!disabled &&
|
|
!polishingInput &&
|
|
(mentionButtonOpen || mentionQuery !== null);
|
|
useLayoutEffect(() => {
|
|
if (!showMentions) return;
|
|
const place = () => {
|
|
const box = promptRootRef.current?.getBoundingClientRect();
|
|
if (!box) return;
|
|
const top = box.top - 12;
|
|
const bottom = window.innerHeight - box.bottom - 12;
|
|
const above = top >= bottom;
|
|
setMentionPlacement({
|
|
above,
|
|
maxHeight: Math.max(100, above ? top : bottom),
|
|
});
|
|
};
|
|
place();
|
|
window.addEventListener("resize", place);
|
|
window.addEventListener("scroll", place, true);
|
|
return () => {
|
|
window.removeEventListener("resize", place);
|
|
window.removeEventListener("scroll", place, true);
|
|
};
|
|
}, [showMentions]);
|
|
const closeMentions = useCallback(() => {
|
|
if (mentionInFlight.current) return;
|
|
dismissedMention.current = `${textInput.value}:${mentionQuery?.end}`;
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
setMentionError(null);
|
|
}, [textInput.value, mentionQuery]);
|
|
const openMentions = useCallback(() => {
|
|
dismissedMention.current = null;
|
|
const caret = inlineSkillTextRef.current
|
|
? referenceCaret(inlineSkillTextRef.current)
|
|
: textareaRef.current?.selectionStart;
|
|
if (caret != null) composerCaretRef.current = caret;
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(true);
|
|
setMentionError(null);
|
|
}, []);
|
|
const updateMentionQuery = useCallback(
|
|
(value: string, caret: number | null) => {
|
|
if (caret !== null) composerCaretRef.current = caret;
|
|
if (dismissedMention.current === `${value}:${caret}`) return;
|
|
dismissedMention.current = null;
|
|
setMentionButtonOpen(false);
|
|
setMentionError(null);
|
|
setMentionQuery(caret === null ? null : getMentionQuery(value, caret));
|
|
},
|
|
[],
|
|
);
|
|
const focusComposerAt = useCallback((offset: number) => {
|
|
requestAnimationFrame(() => {
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
textarea.focus();
|
|
textarea.setSelectionRange(offset, offset);
|
|
} else if (inlineSkillTextRef.current)
|
|
focusReferenceAt(inlineSkillTextRef.current, offset);
|
|
});
|
|
}, []);
|
|
const selectMention = useCallback(
|
|
async (selection: MentionSelection) => {
|
|
if (mentionInFlight.current || disabled || polishingInput) return;
|
|
const epoch = mentionEpoch.current;
|
|
const originalText = textInput.value;
|
|
const caret =
|
|
mentionQuery?.start ??
|
|
Math.min(composerCaretRef.current, originalText.length);
|
|
const selectionId =
|
|
selection.kind === "skill"
|
|
? selection.skill.name
|
|
: selection.kind === "conversation"
|
|
? selection.reference.threadId
|
|
: selection.kind === "extension"
|
|
? extensionMentionId(selection.reference)
|
|
: null;
|
|
const selected =
|
|
selectionId &&
|
|
inlineReferences(originalText).some(
|
|
(ref) => ref.kind === selection.kind && ref.id === selectionId,
|
|
);
|
|
if (selected) {
|
|
let next = mentionQuery
|
|
? originalText.slice(0, mentionQuery.start) +
|
|
originalText.slice(mentionQuery.end)
|
|
: originalText;
|
|
for (const ref of inlineReferences(next).reverse()) {
|
|
if (ref.kind === selection.kind && ref.id === selectionId)
|
|
next = next.slice(0, ref.start) + next.slice(ref.end);
|
|
}
|
|
if (selection.kind === "conversation")
|
|
setConversationReferences((previous) =>
|
|
previous.filter((ref) => ref.threadId !== selectionId),
|
|
);
|
|
textInput.setInput(next);
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
focusComposerAt(Math.min(caret, next.length));
|
|
return;
|
|
}
|
|
let token = "";
|
|
if (selection.kind === "skill")
|
|
token = referenceToken(
|
|
"skill",
|
|
selection.skill.name,
|
|
selection.skill.name,
|
|
);
|
|
if (selection.kind === "conversation")
|
|
token = referenceToken(
|
|
"conversation",
|
|
selection.reference.threadId,
|
|
selection.reference.title,
|
|
);
|
|
if (selection.kind === "extension")
|
|
token = referenceToken(
|
|
"extension",
|
|
extensionMentionId(selection.reference),
|
|
selection.reference.label,
|
|
);
|
|
if (selection.kind === "file")
|
|
token = referenceToken(
|
|
"file",
|
|
selection.document.id,
|
|
selection.document.name,
|
|
);
|
|
const inserted = token ? token + " " : "";
|
|
const nextText =
|
|
originalText.slice(0, caret) +
|
|
inserted +
|
|
originalText.slice(mentionQuery?.end ?? caret);
|
|
if (selection.kind === "file") {
|
|
if (!projectId) return;
|
|
const duplicate = projectAttachments.some(
|
|
(file) =>
|
|
file.source_document_id === selection.document.id &&
|
|
file.source_project_id === projectId,
|
|
);
|
|
if (!duplicate) {
|
|
const allSizes = [
|
|
...projectAttachments.map((file) => file.size_bytes),
|
|
...attachments.files.map((file) => file.file?.size ?? 0),
|
|
selection.document.size_bytes,
|
|
];
|
|
if (
|
|
uploadLimits &&
|
|
(allSizes.length > uploadLimits.max_files ||
|
|
allSizes.reduce((a, b) => a + b, 0) >
|
|
uploadLimits.max_total_size ||
|
|
selection.document.size_bytes > uploadLimits.max_file_size)
|
|
) {
|
|
setMentionError(
|
|
t.uploads.limitsHint(
|
|
uploadLimits.max_files,
|
|
formatUploadSize(uploadLimits.max_file_size),
|
|
formatUploadSize(uploadLimits.max_total_size),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
mentionInFlight.current = true;
|
|
setMentionBusy(true);
|
|
setMentionError(null);
|
|
try {
|
|
await onPrepareThread?.();
|
|
if (epoch !== mentionEpoch.current) return;
|
|
const attached = await attachProjectDocument(
|
|
projectId,
|
|
selection.document.id,
|
|
threadId,
|
|
);
|
|
if (epoch !== mentionEpoch.current) return;
|
|
setProjectAttachments((previous) => [
|
|
...previous.filter(
|
|
(file) => file.virtual_path !== attached.virtual_path,
|
|
),
|
|
{
|
|
...attached,
|
|
source_document_id: selection.document.id,
|
|
source_project_id: projectId,
|
|
},
|
|
]);
|
|
} catch {
|
|
if (epoch === mentionEpoch.current)
|
|
setMentionError(t.inputBox.mentionAttachFailed);
|
|
return;
|
|
} finally {
|
|
if (epoch === mentionEpoch.current) {
|
|
mentionInFlight.current = false;
|
|
setMentionBusy(false);
|
|
}
|
|
}
|
|
}
|
|
} else if (selection.kind === "conversation") {
|
|
setConversationReferences((previous) =>
|
|
previous.some(
|
|
(item) => item.threadId === selection.reference.threadId,
|
|
)
|
|
? previous
|
|
: [...previous, selection.reference],
|
|
);
|
|
} else if (selection.kind === "upload") {
|
|
// Keep the native file dialog in the user gesture for browser permission.
|
|
attachments.openFileDialog();
|
|
}
|
|
if (
|
|
selection.kind === "file" &&
|
|
onReferenceFileAttached &&
|
|
draftThreadId !== threadId
|
|
) {
|
|
// A prepared new conversation now owns real files. Move its draft to
|
|
// the durable identity before the page replaces /new with that ID.
|
|
const storage = getSessionComposerDraftStorage();
|
|
const nextKey = buildComposerDraftKey({
|
|
userId: user?.id ?? "anonymous",
|
|
agentName:
|
|
draftAgentName ??
|
|
(typeof context.agent_name === "string"
|
|
? context.agent_name
|
|
: null),
|
|
threadId,
|
|
});
|
|
writeComposerDraft(storage, nextKey, {
|
|
text: nextText,
|
|
skillName: null,
|
|
...(conversationReferences.length ? { conversationReferences } : {}),
|
|
});
|
|
invalidateDraftSaveTimer();
|
|
latestDraftRef.current = null;
|
|
clearComposerDraft(storage, draftKey);
|
|
}
|
|
textInput.setInput(nextText);
|
|
setMentionQuery(null);
|
|
setMentionButtonOpen(false);
|
|
setMentionError(null);
|
|
focusComposerAt(caret + inserted.length);
|
|
if (selection.kind === "file") onReferenceFileAttached?.();
|
|
},
|
|
[
|
|
attachments,
|
|
context.agent_name,
|
|
conversationReferences,
|
|
draftAgentName,
|
|
draftKey,
|
|
draftThreadId,
|
|
invalidateDraftSaveTimer,
|
|
onReferenceFileAttached,
|
|
user?.id,
|
|
disabled,
|
|
focusComposerAt,
|
|
mentionQuery,
|
|
onPrepareThread,
|
|
polishingInput,
|
|
projectAttachments,
|
|
projectId,
|
|
setProjectAttachments,
|
|
t,
|
|
textInput,
|
|
threadId,
|
|
uploadLimits,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
if (!showMentions) return;
|
|
const dismiss = (event: PointerEvent) => {
|
|
if (
|
|
event.target instanceof Node &&
|
|
!promptRootRef.current?.contains(event.target)
|
|
)
|
|
closeMentions();
|
|
};
|
|
document.addEventListener("pointerdown", dismiss);
|
|
return () => document.removeEventListener("pointerdown", dismiss);
|
|
}, [showMentions, closeMentions]);
|
|
// 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" ||
|
|
slashCommandQuery !== 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 (inlineSkillTextRef.current) {
|
|
focusContentEditableEnd(inlineSkillTextRef.current);
|
|
} else {
|
|
textareaRef.current?.focus();
|
|
}
|
|
});
|
|
}
|
|
return true;
|
|
} catch {
|
|
cleanupVoiceRecognition(recognition);
|
|
toast.error(t.inputBox.voiceInputFailed);
|
|
return false;
|
|
}
|
|
},
|
|
[
|
|
cleanupVoiceRecognition,
|
|
composerLocked,
|
|
getVoiceInputErrorMessage,
|
|
locale,
|
|
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(() => {
|
|
setCommandSuggestionIndex(0);
|
|
}, [slashCommandQuery, commandSuggestions.length]);
|
|
|
|
const applyCommandSuggestion = useCallback(
|
|
(suggestion: SlashCommandSuggestion) => {
|
|
const nextValue = `/${suggestion.name} `;
|
|
// Commit the controlled text before moving the caret so React's selection
|
|
// restoration cannot put fast typing back inside the command name.
|
|
flushSync(() => {
|
|
textInput.setInput(nextValue);
|
|
setDismissedCommandSuggestionValue(nextValue);
|
|
});
|
|
const editor = inlineSkillTextRef.current;
|
|
if (editor) {
|
|
focusReferenceAt(editor, nextValue.length);
|
|
} else {
|
|
focusComposerAt(nextValue.length);
|
|
}
|
|
},
|
|
[focusComposerAt, textInput],
|
|
);
|
|
|
|
const handleCommandSuggestionKeyDown = useCallback(
|
|
(event: KeyboardEvent<HTMLElement>) => {
|
|
if (!showCommandSuggestions) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setCommandSuggestionIndex(
|
|
(index) => (index + 1) % commandSuggestions.length,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setCommandSuggestionIndex(
|
|
(index) =>
|
|
(index - 1 + commandSuggestions.length) % commandSuggestions.length,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter" || event.key === "Tab") {
|
|
if (event.shiftKey) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const selectedCommand = commandSuggestions[commandSuggestionIndex];
|
|
if (selectedCommand) {
|
|
applyCommandSuggestion(selectedCommand);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setDismissedCommandSuggestionValue(textInput.value);
|
|
}
|
|
},
|
|
[
|
|
applyCommandSuggestion,
|
|
showCommandSuggestions,
|
|
commandSuggestionIndex,
|
|
commandSuggestions,
|
|
textInput.value,
|
|
],
|
|
);
|
|
|
|
const setPromptHistoryValue = useCallback(
|
|
(value: string) => {
|
|
textInput.setInput(value);
|
|
requestAnimationFrame(() => {
|
|
if (inlineSkillTextRef.current)
|
|
focusReferenceAt(inlineSkillTextRef.current, value.length);
|
|
else {
|
|
const textarea = textareaRef.current;
|
|
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: readableReferences(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 = restoreReferenceLabels(
|
|
originalText,
|
|
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) ||
|
|
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, setPromptHistoryValue, 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)) {
|
|
if (showMentions) mentionPickerRef.current?.onKeyDown(event);
|
|
if (event.defaultPrevented) return;
|
|
handleCommandSuggestionKeyDown(event);
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
}
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
handlePromptHistoryKeyDown(event);
|
|
},
|
|
[showMentions, handlePromptHistoryKeyDown, handleCommandSuggestionKeyDown],
|
|
);
|
|
|
|
const handlePromptTextareaChange = useCallback(
|
|
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
acceptedDraftRef.current = null;
|
|
pendingDraftSubmissionRef.current = null;
|
|
updateMentionQuery(
|
|
event.currentTarget.value,
|
|
event.currentTarget.selectionStart,
|
|
);
|
|
if (voiceListening) {
|
|
abortVoiceInput();
|
|
}
|
|
abortInputPolishRequest();
|
|
setInputPolishUndo(null);
|
|
promptHistoryIndexRef.current = null;
|
|
promptHistoryDraftRef.current = "";
|
|
scheduleDraftSave({
|
|
text: event.currentTarget.value,
|
|
skillName: null,
|
|
});
|
|
},
|
|
[
|
|
abortInputPolishRequest,
|
|
abortVoiceInput,
|
|
scheduleDraftSave,
|
|
updateMentionQuery,
|
|
voiceListening,
|
|
],
|
|
);
|
|
|
|
const updateInlineSkillTextInput = useCallback(
|
|
(element: HTMLElement) => {
|
|
acceptedDraftRef.current = null;
|
|
pendingDraftSubmissionRef.current = null;
|
|
if (voiceListening) {
|
|
abortVoiceInput();
|
|
}
|
|
promptHistoryIndexRef.current = null;
|
|
promptHistoryDraftRef.current = "";
|
|
const nextText = readReferenceEditor(element);
|
|
const refs = inlineReferences(nextText);
|
|
setConversationReferences((previous) => {
|
|
const known = new Map(conversationReferenceCache.current);
|
|
for (const reference of previous)
|
|
known.set(reference.threadId, reference);
|
|
return reconcileConversationReferences(
|
|
nextText,
|
|
[...known.values()],
|
|
conversationCapability,
|
|
threadId,
|
|
).references;
|
|
});
|
|
setProjectAttachments((previous) => {
|
|
const ids = new Set(
|
|
refs.filter((ref) => ref.kind === "file").map((ref) => ref.id),
|
|
);
|
|
return [
|
|
...previous.filter((file) => !file.source_document_id),
|
|
...[...ids].flatMap((id) => {
|
|
const file = projectReferenceCache.current.get(id);
|
|
return file ? [file] : [];
|
|
}),
|
|
];
|
|
});
|
|
abortInputPolishRequest();
|
|
setInputPolishUndo(null);
|
|
const caret = referenceCaret(element);
|
|
textInput.setInput(nextText);
|
|
updateMentionQuery(nextText, caret);
|
|
// The inline editor stays mounted after its last reference is removed.
|
|
// Keep its native caret instead of scheduling a stale position that can
|
|
// overwrite the caret set by a subsequently selected command.
|
|
scheduleDraftSave({
|
|
text: nextText,
|
|
skillName: null,
|
|
});
|
|
},
|
|
[
|
|
conversationCapability,
|
|
threadId,
|
|
abortVoiceInput,
|
|
abortInputPolishRequest,
|
|
setProjectAttachments,
|
|
scheduleDraftSave,
|
|
textInput,
|
|
updateMentionQuery,
|
|
voiceListening,
|
|
],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
if (hasInlineReferences) setInlineEditorActive(true);
|
|
const element = inlineSkillTextRef.current;
|
|
if (element && !inlineSkillComposingRef.current)
|
|
renderReferenceEditor(element, textInput.value);
|
|
}, [hasInlineReferences, 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>) => {
|
|
if (isCompositionConfirmEnter(event, inlineCompositionEndedAt.current)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
// 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)) {
|
|
if (
|
|
!composerLocked &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
!event.shiftKey &&
|
|
(event.key === "Backspace" || event.key === "Delete") &&
|
|
selectReferenceForDeletion(event.currentTarget, event.key)
|
|
)
|
|
return;
|
|
if (showMentions) mentionPickerRef.current?.onKeyDown(event);
|
|
if (event.defaultPrevented) return;
|
|
handleCommandSuggestionKeyDown(event);
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
handlePromptHistoryKeyDown(event);
|
|
if (event.defaultPrevented || 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();
|
|
},
|
|
[
|
|
composerLocked,
|
|
showMentions,
|
|
handlePromptHistoryKeyDown,
|
|
handleCommandSuggestionKeyDown,
|
|
updateInlineSkillTextInput,
|
|
],
|
|
);
|
|
|
|
const showFollowups =
|
|
!disabled &&
|
|
!isWelcomeMode &&
|
|
!showCommandSuggestions &&
|
|
!showMentions &&
|
|
!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>
|
|
)}
|
|
{showMentions && (
|
|
<div
|
|
className={cn(
|
|
"absolute right-0 left-0 z-50 overflow-y-auto px-1",
|
|
mentionPlacement.above ? "bottom-full mb-2" : "top-full mt-2",
|
|
)}
|
|
style={{ maxHeight: mentionPlacement.maxHeight }}
|
|
>
|
|
<MentionPicker
|
|
selectedExtensions={inlineReferences(textInput.value)
|
|
.filter((ref) => ref.kind === "extension")
|
|
.map((ref) => ref.id)}
|
|
ref={mentionPickerRef}
|
|
listId={mentionListId}
|
|
query={mentionQuery?.query ?? ""}
|
|
searchInput={mentionButtonOpen}
|
|
skills={mentionSkills}
|
|
selectedSkills={inlineRefs
|
|
.filter((ref) => ref.kind === "skill")
|
|
.map((ref) => ref.id)}
|
|
references={reconciledConversations.references}
|
|
capability={conversationCapability}
|
|
skillsLoading={skillsLoading || skillsFetching}
|
|
skillsError={skillsError}
|
|
onRetrySkills={refetchSkills}
|
|
onActiveOptionChange={setActiveMentionOption}
|
|
threadId={threadId}
|
|
projectId={projectId}
|
|
busy={mentionBusy}
|
|
error={mentionError}
|
|
onSelect={(selection) => void selectMention(selection)}
|
|
onClose={() => {
|
|
closeMentions();
|
|
focusComposerAt(mentionQuery?.end ?? textInput.value.length);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{showCommandSuggestions && !showMentions && (
|
|
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
|
|
<div
|
|
aria-label="Command 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"
|
|
>
|
|
{commandSuggestions.map((suggestion, index) => {
|
|
const selected = index === commandSuggestionIndex;
|
|
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.name}
|
|
onClick={() => applyCommandSuggestion(suggestion)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onMouseEnter={() => setCommandSuggestionIndex(index)}
|
|
role="option"
|
|
type="button"
|
|
>
|
|
<TargetIcon 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">
|
|
{!!conversationCapability.error &&
|
|
inlineRefs.some((ref) => ref.kind === "conversation") && (
|
|
<div role="alert" className="w-full text-xs">
|
|
{t.inputBox.mentionFailed}{" "}
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
data-testid="retry-conversation-capability"
|
|
onClick={() => void conversationCapability.refetch()}
|
|
>
|
|
{t.inputBox.mentionRetry}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<PromptInputAttachments className="contents p-0">
|
|
{(attachment) => (
|
|
<div className="max-w-60">
|
|
<PromptInputAttachment data={attachment} />
|
|
</div>
|
|
)}
|
|
</PromptInputAttachments>
|
|
{projectAttachments
|
|
.filter((attachment) => !attachment.source_document_id)
|
|
.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>
|
|
))}
|
|
{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">
|
|
{hasInlineReferences || inlineEditorActive ? (
|
|
<div
|
|
className="max-h-48 min-h-6 w-full min-w-0 cursor-text overflow-y-auto text-base leading-7 break-words whitespace-pre-wrap md:text-sm"
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
focusContentEditableEnd(inlineSkillTextRef.current);
|
|
}
|
|
}}
|
|
>
|
|
<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;
|
|
inlineCompositionEndedAt.current = Date.now();
|
|
}}
|
|
onCompositionStart={() => {
|
|
inlineSkillComposingRef.current = true;
|
|
}}
|
|
onFocus={() => setTextareaFocused(true)}
|
|
aria-controls={showMentions ? mentionListId : undefined}
|
|
aria-activedescendant={
|
|
showMentions ? activeMentionOption : undefined
|
|
}
|
|
onClick={(event) =>
|
|
updateMentionQuery(
|
|
readReferenceEditor(event.currentTarget),
|
|
referenceCaret(event.currentTarget),
|
|
)
|
|
}
|
|
onKeyUp={(event) => {
|
|
if (
|
|
["ArrowLeft", "ArrowRight", "Home", "End"].includes(
|
|
event.key,
|
|
)
|
|
)
|
|
updateMentionQuery(
|
|
readReferenceEditor(event.currentTarget),
|
|
referenceCaret(event.currentTarget),
|
|
);
|
|
}}
|
|
onInput={handleInlineSkillInput}
|
|
onKeyDown={handleInlineSkillKeyDown}
|
|
onPaste={handleInlineSkillPaste}
|
|
aria-placeholder={t.inputBox.placeholder}
|
|
ref={inlineSkillTextRef}
|
|
role="textbox"
|
|
suppressContentEditableWarning
|
|
className={cn(
|
|
"block min-h-6 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)}
|
|
aria-controls={showMentions ? mentionListId : undefined}
|
|
aria-activedescendant={
|
|
showMentions ? activeMentionOption : undefined
|
|
}
|
|
onSelect={(event) => {
|
|
const input = event.currentTarget;
|
|
if (!mentionButtonOpen && !mentionInFlight.current)
|
|
updateMentionQuery(
|
|
input.value,
|
|
input.selectionStart === input.selectionEnd
|
|
? input.selectionStart
|
|
: null,
|
|
);
|
|
}}
|
|
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">
|
|
<Tooltip content={t.inputBox.mentionPicker}>
|
|
<PromptInputButton
|
|
aria-label={t.inputBox.mentionPicker}
|
|
data-testid="mention-button"
|
|
disabled={composerLocked}
|
|
onClick={openMentions}
|
|
>
|
|
<AtSignIcon className="size-4" />
|
|
</PromptInputButton>
|
|
</Tooltip>
|
|
<AddAttachmentsButton
|
|
onOpen={openMentions}
|
|
className="px-2!"
|
|
disabled={composerLocked}
|
|
uploadLimits={uploadLimits}
|
|
/>
|
|
<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 ||
|
|
(status !== "streaming" && conversationReferencesUnavailable)
|
|
}
|
|
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" &&
|
|
!showCommandSuggestions &&
|
|
!showMentions && (
|
|
<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,
|
|
onOpen,
|
|
}: {
|
|
onOpen?: () => void;
|
|
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={onOpen ?? (() => attachments.openFileDialog())}
|
|
>
|
|
<PaperclipIcon className="size-3" />
|
|
</PromptInputButton>
|
|
</Tooltip>
|
|
);
|
|
}
|