feat: 前端输入框体验优化

- AI 响应完成后自动聚焦输入框
- 输入框和发送按钮居中对齐
- 隐藏输入框滚动条
- 新建话题无需输入名称,自动生成默认标题
This commit is contained in:
ooodc 2026-05-29 23:12:53 +08:00
parent 06756a4816
commit 3d9c981c2a
5 changed files with 32 additions and 13 deletions

View File

@ -142,12 +142,9 @@ function App() {
return return
} }
const title = prompt('Enter topic title:') const cmd = createTopic()
if (title) { handleCommand(cmd)
const cmd = createTopic(title) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}
}, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly]) }, [sendMessage, handleCommand, createTopic, sessionId, isReadOnly])
const handleSwitchTopic = useCallback( const handleSwitchTopic = useCallback(

View File

@ -27,6 +27,7 @@ export function ChatContainer({
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
channelName={channelName} channelName={channelName}
/> />

View File

@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react'
interface MessageInputProps { interface MessageInputProps {
onSend: (content: string) => void onSend: (content: string) => void
disabled?: boolean disabled?: boolean
isLoading?: boolean
placeholder?: string placeholder?: string
isReadOnly?: boolean isReadOnly?: boolean
channelName?: string channelName?: string
@ -12,12 +13,14 @@ interface MessageInputProps {
export function MessageInput({ export function MessageInput({
onSend, onSend,
disabled = false, disabled = false,
isLoading = false,
placeholder = '输入消息...按 / 查看命令', placeholder = '输入消息...按 / 查看命令',
isReadOnly = false, isReadOnly = false,
channelName, channelName,
}: MessageInputProps) { }: MessageInputProps) {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const wasLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current const textarea = textareaRef.current
@ -27,6 +30,14 @@ export function MessageInput({
} }
}, [content]) }, [content])
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
useEffect(() => {
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
textareaRef.current?.focus()
}
wasLoadingRef.current = isLoading
}, [isLoading, isReadOnly])
const handleSend = () => { const handleSend = () => {
if (content.trim() && !disabled && !isReadOnly) { if (content.trim() && !disabled && !isReadOnly) {
onSend(content.trim()) onSend(content.trim())
@ -74,8 +85,8 @@ export function MessageInput({
return ( return (
<div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4"> <div className="border-t border-white/8 bg-[#12121a]/80 backdrop-blur-md p-4">
<div className="flex gap-3 items-end max-w-5xl mx-auto"> <div className="flex gap-3 items-center max-w-5xl mx-auto">
<div className="flex-1 relative"> <div className="flex-1 relative flex items-center">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={content} value={content}
@ -84,14 +95,14 @@ export function MessageInput({
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
rows={1} rows={1}
className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all" className="w-full resize-none rounded-xl border border-white/10 bg-[#1a1a25] px-4 py-3 pr-12 text-sm text-white placeholder:text-zinc-500 focus:border-[#00f0ff]/50 focus:outline-none focus:ring-1 focus:ring-[#00f0ff]/20 disabled:opacity-50 transition-all self-center scrollbar-hide"
/> />
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" /> <Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600 pointer-events-none" />
</div> </div>
<button <button
onClick={handleSend} onClick={handleSend}
disabled={disabled || !content.trim()} disabled={disabled || !content.trim()}
className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[#00f0ff] to-[#3b82f6] text-white shadow-lg shadow-[#00f0ff]/20 hover:shadow-xl hover:shadow-[#00f0ff]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
> >
{disabled ? ( {disabled ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />

View File

@ -47,7 +47,7 @@ interface UseChatReturn {
// Topic 方法 // Topic 方法
selectTopic: (topicId: string) => void selectTopic: (topicId: string) => void
createTopic: (title: string) => Command createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command switchTopic: (topicId: string) => Command
// 初始化方法 // 初始化方法
@ -425,10 +425,10 @@ export function useChat(): UseChatReturn {
setMessages([]) setMessages([])
}, []) }, [])
const createTopic = useCallback((title: string): Command => { const createTopic = useCallback((title?: string): Command => {
return { return {
type: 'create_session', type: 'create_session',
title, title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
} }
}, []) }, [])

View File

@ -45,6 +45,16 @@
background: var(--accent-blue); background: var(--accent-blue);
} }
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Base styles */ /* Base styles */
* { * {
box-sizing: border-box; box-sizing: border-box;