multica/packages/ui/src/components/chat-input.tsx
Naiyuan Qing 53c350ea33 refactor(ui,hooks): extract shared ChatView and DevicePairing to packages
- Extract ChatView from web chat-page into packages/ui as a prop-driven
  component (accepts UseChatReturn shape, no transport dependency)
- Move DevicePairing from apps/web to packages/ui with locally defined
  ConnectionIdentity type (no @multica/hooks dependency)
- Create @multica/hooks package with useGatewayConnection and useChat
  (moved from apps/web/hooks)
- Add isLoadingHistory state to useChat with skeleton loading in ChatView
- Add MulticaIcon (pure CSS asterisk via clip-path, adapts to theme)
- Slim web chat-page.tsx from 188 to 65 lines (just wires hooks to UI)

Desktop can now reuse ChatView and DevicePairing directly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:28:44 +08:00

126 lines
4 KiB
TypeScript

"use client";
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Button } from "@multica/ui/components/ui/button";
import { ArrowUpIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@multica/ui/lib/utils";
import "./chat-input.css";
export interface ChatInputRef {
getText: () => string;
setText: (text: string) => void;
focus: () => void;
clear: () => void;
}
interface ChatInputProps {
onSubmit?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) {
// Use ref to avoid stale closure in Tiptap keydown handler
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable all rich-text features — plain text only
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
}),
Placeholder.configure({ placeholder }),
],
immediatelyRender: false,
editorProps: {
attributes: {
class:
"w-full resize-none bg-transparent px-1 py-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
},
handleKeyDown(_view, event) {
// Guard for IME composition (Chinese/Japanese input)
if (event.isComposing) return false;
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const text = _view.state.doc.textContent;
if (!text.trim()) return true;
onSubmitRef.current?.(text);
// Clear editor after submit
_view.dispatch(
_view.state.tr
.delete(0, _view.state.doc.content.size)
.setMeta("addToHistory", false),
);
return true;
}
return false;
},
},
});
// Sync disabled state
useEffect(() => {
if (!editor) return;
editor.setEditable(!disabled);
}, [editor, disabled]);
// Sync placeholder
useEffect(() => {
if (!editor) return;
editor.extensionManager.extensions.find(
(ext) => ext.name === "placeholder",
)!.options.placeholder = placeholder;
// Force view update so placeholder re-renders
editor.view.dispatch(editor.state.tr);
}, [editor, placeholder]);
// Expose imperative API
useImperativeHandle(ref, () => ({
getText: () => editor?.state.doc.textContent ?? "",
setText: (text: string) => {
editor?.commands.setContent(text ? `<p>${text}</p>` : "");
},
focus: () => editor?.commands.focus(),
clear: () => editor?.commands.clearContent(),
}), [editor]);
const handleSubmit = () => {
if (!editor) return;
const text = editor.state.doc.textContent;
if (!text.trim()) return;
onSubmit?.(text);
editor.commands.clearContent();
};
return (
<div className={cn(
"chat-input-editor bg-card rounded-xl p-3 border border-border transition-colors",
disabled && "is-disabled cursor-not-allowed opacity-60",
)}>
<EditorContent editor={editor} />
<div className="flex items-center justify-end pt-2">
<Button size="icon-lg" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon className="size-4.5" strokeWidth={2.5} icon={ArrowUpIcon} />
</Button>
</div>
</div>
);
},
);