Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

534 lines
19 KiB
TypeScript

"use client";
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Bot, Check, UserRound } from "lucide-react";
import ChatSpaceMenu, {
type ChatSpaceSelectionCounts,
} from "@/components/chat/space/ChatSpaceMenu";
import { agentGlyph } from "@/components/agents/agent-icons";
import { shouldSubmitOnEnter } from "@/lib/composer-keyboard";
import { useAutoSizedTextarea } from "@/lib/use-auto-sized-textarea";
interface ComposerInputProps {
textareaRef: RefObject<HTMLTextAreaElement | null>;
isVisualizeMode: boolean;
// When true, parent has attachments/references queued and will accept a
// send even if the text body is empty. Without this, Enter would silently
// do nothing for an attachment-only message.
canSendEmpty: boolean;
onSend: (content: string) => void;
onInputChange: (content: string) => void;
onPaste: (e: React.ClipboardEvent) => void;
selectedCounts: ChatSpaceSelectionCounts;
/**
* Hide the Knowledge entry in the @ menu. Knowledge now lives in the
* toolbar KnowledgeSelector chip, so this is currently always false —
* kept as a prop in case a surface wants the @ entry back.
*/
knowledgeAvailable: boolean;
/** Hide the Persona entry (main chat: persona has its own selector). */
personaAvailable: boolean;
/**
* Connected subagents selectable via the ``@`` mention. When provided, ``@``
* opens an agent picker (the main-chat behavior) instead of the Space menu;
* surfaces that omit this (e.g. the quiz follow-up) keep the Space menu on @.
*/
connectedAgents?: { name: string; kind?: string }[];
selectedAgent?: string | null;
onSelectAgent?: (name: string | null) => void;
onSelectAttach: () => void;
onSelectKnowledge?: () => void;
onSelectNotebookPicker: () => void;
onSelectBookPicker: () => void;
onSelectHistoryPicker: () => void;
onSelectAgentsPicker?: () => void;
/** Hide the My Agents entry (e.g. the quiz follow-up surface). */
agentsAvailable?: boolean;
onSelectQuestionBankPicker: () => void;
onSelectPersonaPicker: () => void;
onSelectMemoryPicker: () => void;
/**
* Wires the `/persona` slash command. Typing "/" (then any prefix of
* "persona") at the start of an empty composer pops a command hint;
* selecting it clears the input and invokes this callback to open the
* session persona selector. Omitted on surfaces without session
* personas (e.g. the quiz follow-up), which disables the slash popup.
*/
onOpenPersonaSelector?: () => void;
/**
* Override the default placeholder. When unset, falls back to the
* main chat ("How can I help you today?") / visualize defaults.
*/
placeholder?: string;
/**
* Minimum textarea height in pixels. The auto-sized hook grows the
* textarea past this as the user types. Bumped on the empty-state
* composer so the resting box looks inviting rather than crammed.
*/
minHeight?: number;
}
export interface ComposerInputHandle {
clear: () => void;
getValue: () => string;
/**
* Programmatically replace the textarea contents (used by the
* ``AskUserOptions`` chip click handler — picks an option, prefills
* the composer, leaves it to the user to edit/send rather than
* auto-firing the message).
*/
setValue: (value: string) => void;
}
export function shouldOpenAtPopup(value: string, cursorPos: number): boolean {
const prefix = value.slice(0, cursorPos);
return /(^|\s)@[^\s]*$/.test(prefix);
}
export function stripTrailingAtMention(value: string): string {
return value.replace(/(^|\s)@[^\s]*$/, "$1").replace(/\s+$/, "");
}
/** The text typed after a trailing ``@`` (the agent-mention query), or "". */
export function atMentionQuery(value: string, cursorPos: number): string {
const match = /(^|\s)@([^\s]*)$/.exec(value.slice(0, cursorPos));
return match ? match[2] : "";
}
/**
* `/persona` slash-command detection (Codex-style: command position is the
* very start of the input, not mid-text like @ mentions). Active while the
* text before the cursor is "/" plus any prefix of "persona" — `/x` or a
* trailing space closes the popup.
*/
export function shouldOpenSlashPopup(
value: string,
cursorPos: number,
): boolean {
const prefix = value.slice(0, cursorPos);
const match = /^\/([a-z]*)$/i.exec(prefix);
if (!match) return false;
return "persona".startsWith(match[1].toLowerCase());
}
export const ComposerInput = memo(
forwardRef<ComposerInputHandle, ComposerInputProps>(function ComposerInput(
{
textareaRef,
isVisualizeMode,
canSendEmpty,
onSend,
onInputChange,
onPaste,
selectedCounts,
knowledgeAvailable,
personaAvailable,
connectedAgents = [],
selectedAgent = null,
onSelectAgent,
onSelectAttach,
onSelectKnowledge,
onSelectNotebookPicker,
onSelectBookPicker,
onSelectHistoryPicker,
onSelectAgentsPicker,
agentsAvailable = true,
onSelectQuestionBankPicker,
onSelectPersonaPicker,
onSelectMemoryPicker,
onOpenPersonaSelector,
placeholder,
minHeight = 28,
},
ref,
) {
const { t } = useTranslation();
const [input, setInput] = useState("");
const [showAtPopup, setShowAtPopup] = useState(false);
const [showSlashPopup, setShowSlashPopup] = useState(false);
const [atQuery, setAtQuery] = useState("");
const slashEnabled = Boolean(onOpenPersonaSelector);
// Main chat passes ``onSelectAgent`` → ``@`` picks a connected agent. Other
// surfaces (quiz follow-up) omit it and keep the @ Space menu.
const agentMentionMode = Boolean(onSelectAgent);
const filteredAgents = agentMentionMode
? connectedAgents.filter((a) =>
a.name.toLowerCase().includes(atQuery.toLowerCase()),
)
: [];
// Latest text mirrored into a ref by the change handlers (never updated
// during render). The @space handlers and the imperative handle read
// from this ref so their identities stay stable across keystrokes,
// letting `memo` on ChatSpaceMenu actually skip re-renders when
// `showAtPopup` doesn't change.
const inputRef = useRef("");
const isComposingRef = useRef(false);
// Helper that always updates state and ref together so they can't drift.
const setInputBoth = useCallback((value: string) => {
inputRef.current = value;
setInput(value);
}, []);
useImperativeHandle(
ref,
() => ({
clear: () => {
setInputBoth("");
onInputChange("");
},
getValue: () => inputRef.current,
setValue: (value: string) => {
const text = value ?? "";
setInputBoth(text);
onInputChange(text);
// Focus + move caret to the end so the user can immediately
// edit or press Enter to send.
const el = textareaRef.current;
if (el) {
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(text.length, text.length);
});
}
},
}),
[setInputBoth, onInputChange, textareaRef],
);
useAutoSizedTextarea(textareaRef, input, { min: minHeight, max: 200 });
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart ?? value.length;
setInputBoth(value);
onInputChange(value);
const atOpen = shouldOpenAtPopup(value, cursorPos);
setShowAtPopup(atOpen);
setAtQuery(atOpen ? atMentionQuery(value, cursorPos) : "");
setShowSlashPopup(
slashEnabled && shouldOpenSlashPopup(value, cursorPos),
);
},
[setInputBoth, onInputChange, slashEnabled],
);
const handleTextareaClick = useCallback(
(e: React.MouseEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
const cursorPos = target.selectionStart ?? target.value.length;
const atOpen = shouldOpenAtPopup(target.value, cursorPos);
setShowAtPopup(atOpen);
setAtQuery(atOpen ? atMentionQuery(target.value, cursorPos) : "");
setShowSlashPopup(
slashEnabled && shouldOpenSlashPopup(target.value, cursorPos),
);
},
[slashEnabled],
);
const handleSelectSlashPersona = useCallback(() => {
// The slash text is a command, not message content — clear it.
setInputBoth("");
onInputChange("");
setShowSlashPopup(false);
onOpenPersonaSelector?.();
}, [setInputBoth, onInputChange, onOpenPersonaSelector]);
const doSend = useCallback(() => {
const content = inputRef.current.trim();
// Allow sending when text is empty but the parent has attachments or
// references queued (canSendEmpty). This matches the send-button's
// own enablement logic in ChatComposer (`canSend`).
if (!content && !canSendEmpty) return;
onSend(content);
setInputBoth("");
onInputChange("");
setShowAtPopup(false);
setShowSlashPopup(false);
}, [canSendEmpty, onSend, setInputBoth, onInputChange]);
const clearTrailingMention = useCallback(() => {
const next = stripTrailingAtMention(inputRef.current);
setInputBoth(next);
onInputChange(next);
}, [setInputBoth, onInputChange]);
const handleSelectAgentMention = useCallback(
(name: string) => {
clearTrailingMention();
setShowAtPopup(false);
setAtQuery("");
onSelectAgent?.(name);
},
[clearTrailingMention, onSelectAgent],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// With the slash popup open, Enter/Tab confirm the command instead
// of submitting "/persona" as a message.
if (
showSlashPopup &&
!isComposingRef.current &&
(e.key === "Enter" || e.key === "Tab")
) {
e.preventDefault();
handleSelectSlashPersona();
return;
}
// With the agent-mention popup open, Enter/Tab confirm the first match.
if (
showAtPopup &&
agentMentionMode &&
filteredAgents.length > 0 &&
!isComposingRef.current &&
(e.key === "Enter" || e.key === "Tab")
) {
e.preventDefault();
handleSelectAgentMention(filteredAgents[0].name);
return;
}
if (shouldSubmitOnEnter(e, isComposingRef.current)) {
e.preventDefault();
doSend();
} else if (e.key === "Escape") {
setShowAtPopup(false);
setShowSlashPopup(false);
}
},
[
doSend,
showSlashPopup,
handleSelectSlashPersona,
showAtPopup,
agentMentionMode,
filteredAgents,
handleSelectAgentMention,
],
);
const handleCompositionStart = useCallback(() => {
isComposingRef.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
// Some IMEs fire compositionend before the Enter keydown that confirms
// a candidate, so keep the guard through the current event turn.
setTimeout(() => {
isComposingRef.current = false;
}, 0);
}, []);
const handleSelectSpaceItem = useCallback(
(
key:
| "attach"
| "knowledge"
| "chat_history"
| "my_agents"
| "books"
| "notebooks"
| "question_bank"
| "persona"
| "memory",
) => {
clearTrailingMention();
setShowAtPopup(false);
if (key === "attach") onSelectAttach();
else if (key === "knowledge") onSelectKnowledge?.();
else if (key === "chat_history") onSelectHistoryPicker();
else if (key === "my_agents") onSelectAgentsPicker?.();
else if (key === "books") onSelectBookPicker();
else if (key === "notebooks") onSelectNotebookPicker();
else if (key === "question_bank") onSelectQuestionBankPicker();
else if (key === "persona") onSelectPersonaPicker();
else if (key === "memory") onSelectMemoryPicker();
},
[
clearTrailingMention,
onSelectAttach,
onSelectKnowledge,
onSelectHistoryPicker,
onSelectAgentsPicker,
onSelectBookPicker,
onSelectNotebookPicker,
onSelectQuestionBankPicker,
onSelectPersonaPicker,
onSelectMemoryPicker,
],
);
// Close the @/slash popups on outside click. Without this, clicking
// anywhere outside the popup or textarea left the menu hovering
// indefinitely. We bind on mousedown so the close fires before a
// synthetic click on a sibling button (e.g. the Tools menu) can
// re-open something else.
const popupRef = useRef<HTMLDivElement>(null);
const slashPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showAtPopup && !showSlashPopup) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (popupRef.current?.contains(target)) return;
if (slashPopupRef.current?.contains(target)) return;
if (textareaRef.current?.contains(target)) return;
setShowAtPopup(false);
setShowSlashPopup(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showAtPopup, showSlashPopup, textareaRef]);
return (
<div className="px-4 pt-3.5 pb-2">
{showAtPopup && agentMentionMode && (
<div
ref={popupRef}
className="absolute bottom-full left-0 z-[70] mb-2"
>
<div
role="listbox"
aria-label={t("Talk to an agent")}
className="w-[300px] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg backdrop-blur-md"
>
<div className="px-3 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.05em] text-[var(--muted-foreground)]">
{t("Talk to an agent")}
</div>
{filteredAgents.length === 0 ? (
<div className="px-3 py-2 text-[12px] text-[var(--muted-foreground)]">
{connectedAgents.length === 0
? t("No connected agents — connect one in My Agents.")
: t("No matching agent")}
</div>
) : (
<div className="max-h-[260px] overflow-y-auto">
{filteredAgents.map((agent) => {
const Glyph = agentGlyph(agent.kind) ?? Bot;
const active = selectedAgent === agent.name;
return (
<button
key={agent.name}
type="button"
role="option"
aria-selected={active}
onClick={() => handleSelectAgentMention(agent.name)}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors active:bg-[var(--muted)]/70 ${
active
? "bg-[var(--primary)]/[0.06]"
: "hover:bg-[var(--muted)]/45"
}`}
>
<Glyph size={15} className="shrink-0" />
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-[var(--foreground)]">
{agent.name}
</span>
{active && (
<Check
size={14}
strokeWidth={2}
className="shrink-0 text-[var(--primary)]"
/>
)}
</button>
);
})}
</div>
)}
</div>
</div>
)}
{showAtPopup && !agentMentionMode && (
<div
ref={popupRef}
className="absolute bottom-full left-0 z-[70] mb-2"
>
<ChatSpaceMenu
variant="mention"
selectedCounts={selectedCounts}
knowledgeAvailable={knowledgeAvailable}
personaAvailable={personaAvailable}
agentsAvailable={agentsAvailable}
onSelectItem={handleSelectSpaceItem}
/>
</div>
)}
{showSlashPopup && (
<div
ref={slashPopupRef}
className="absolute bottom-full left-0 z-[70] mb-2"
>
<div
role="listbox"
aria-label={t("Commands")}
className="w-[300px] rounded-xl border border-[var(--border)] bg-[var(--popover)] py-1.5 shadow-lg backdrop-blur-md"
>
<button
type="button"
role="option"
aria-selected
onClick={handleSelectSlashPersona}
className="flex w-full items-center gap-2.5 bg-[var(--muted)]/60 px-3 py-2 text-left text-[12.5px] transition-colors"
>
<UserRound
size={14}
strokeWidth={1.7}
className="shrink-0 text-[var(--muted-foreground)]"
/>
{/* Command syntax token — must not be localized. */}
{/* eslint-disable-next-line i18n/no-literal-ui-text */}
<span className="font-medium text-[var(--foreground)]">
/persona
</span>
<span className="min-w-0 truncate text-[var(--muted-foreground)]">
{t("Switch the persona for this chat session")}
</span>
</button>
</div>
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onClick={handleTextareaClick}
onPaste={onPaste}
rows={1}
// Cap input at 32k chars. A bigger paste (e.g. an entire textbook
// dumped via Cmd+V) would force a layout reflow on every keystroke
// and lock the page; the cap is a defensive guard, not a real
// product limit. Users hit by this cap should be using the
// attachment path, not the composer body.
maxLength={32000}
suppressHydrationWarning
placeholder={
placeholder ??
(isVisualizeMode
? t(
"Describe the chart, diagram, or animation you want to visualize...",
)
: t("How can I help you today?"))
}
className="w-full resize-none overflow-hidden bg-transparent text-[16px] leading-relaxed text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
style={{ transition: "height 0.15s ease-out" }}
/>
</div>
);
}),
);