feat(chat): add composer action menu

This commit is contained in:
2026-07-21 10:58:40 +08:00
parent 17e5656f53
commit 7789673fff
11 changed files with 632 additions and 62 deletions
+95 -20
View File
@@ -1,17 +1,24 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils/logger";
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
import { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css";
const log = new Logger("AppChatComponentsChatInputBar");
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
export interface ChatInputBarProps {
disabled?: boolean;
@@ -19,22 +26,57 @@ export interface ChatInputBarProps {
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const [input, setInput] = useState("");
const [isFocused, setIsFocused] = useState(false);
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const [previousDisabled, setPreviousDisabled] = useState(disabled);
const barRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const lastPointerSendAtRef = useRef(0);
const hasContent = input.trim().length > 0;
if (disabled !== previousDisabled) {
setPreviousDisabled(disabled);
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
}
useChatKeyboardAvoidance({
active: isFocused,
containerRef: barRef,
});
useChatKeyboardDiagnostics({ containerRef: barRef });
useEffect(() => {
if (!isActionMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && barRef.current?.contains(target)) return;
setIsActionMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsActionMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isActionMenuOpen]);
const handleInputChange = (value: string) => {
setInput(value);
if (value.trim().length > 0) setIsActionMenuOpen(false);
};
const handleFocusChange = (focused: boolean) => {
setIsFocused(focused);
if (focused) setIsActionMenuOpen(false);
};
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
@@ -62,6 +104,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
});
dispatch({ type: "ChatSendMessage", content: input });
setInput("");
setIsActionMenuOpen(false);
textareaRef.current?.focus();
};
@@ -70,26 +113,58 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
handleSend("pointerdown");
};
const handleMenuToggle = () => {
if (disabled || hasContent) return;
if (!isActionMenuOpen) textareaRef.current?.blur();
setIsActionMenuOpen((open) => !open);
};
const handlePromotion = (promotionType: "image" | "voice") => {
if (disabled) return;
dispatch({
type: "ChatPromotionInjected",
promotion: createPendingChatPromotion(
promotionType,
character.id,
),
});
setIsActionMenuOpen(false);
};
return (
<div
ref={barRef}
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
>
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
<ChatInputTextField
ref={textareaRef}
value={input}
onChange={handleInputChange}
onSubmit={() => handleSend("keyboard")}
onFocusChange={setIsFocused}
disabled={disabled}
/>
<ChatSendButton
disabled={disabled}
hasContent={hasContent}
onClick={() => handleSend("click")}
onPointerDownSend={handlePointerDownSend}
/>
<div ref={barRef} className={styles.bar}>
<div className={styles.composer}>
{isActionMenuOpen ? (
<ChatComposerActionMenu
id={CHAT_ACTION_MENU_ID}
disabled={disabled}
tipHref={characterRoutes.tip}
onImage={() => handlePromotion("image")}
onVoice={() => handlePromotion("voice")}
onNavigate={() => setIsActionMenuOpen(false)}
/>
) : null}
<div
className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
>
<ChatInputTextField
ref={textareaRef}
value={input}
onChange={handleInputChange}
onSubmit={() => handleSend("keyboard")}
onFocusChange={handleFocusChange}
disabled={disabled}
/>
<ChatSendButton
disabled={disabled}
hasContent={hasContent}
isMenuOpen={isActionMenuOpen}
menuId={CHAT_ACTION_MENU_ID}
onClick={() => handleSend("click")}
onMenuToggle={handleMenuToggle}
onPointerDownSend={handlePointerDownSend}
/>
</div>
</div>
</div>
);