Przeglądaj źródła

fix(chat): 修复AI会话输入框无法拉高问题并优化交互体验 v2.2.25

- 修复@dnd-kit/sortable版本错误导致故事框架页面白屏(Cannot read properties of null)
- 修复输入框边界溢出二次修复后无法拉高的问题:pointerdown时缓存初始DOM上下文,拖拽中基于预测位置计算maxHeight
- 输入框高度双向自适应:内容增多自动增高,内容删除后自动收缩
- 智能区分手动/自动模式:用户拖拽后以设定高度为最小值,双击/发送后恢复自动模式
- 持久化用户偏好输入框高度(localStorage: lk-chat-input-height),下次打开自动恢复
- 动态最小消息区域高度(max(80px, 容器高度×15%)),拖拽条颜色反馈,双击重置
- 窗口resize时重新计算边界防止越界
Mochocyang 2 miesięcy temu
rodzic
commit
1cae36e4f0

+ 5 - 5
package-lock.json

@@ -10,7 +10,7 @@
       "dependencies": {
         "@base-ui/react": "^1.3.0",
         "@dnd-kit/core": "^6.3.1",
-        "@dnd-kit/sortable": "^10.0.0",
+        "@dnd-kit/sortable": "^8.0.0",
         "@dnd-kit/utilities": "^3.2.2",
         "@fontsource-variable/geist": "^5.2.8",
         "@milkdown/kit": "^7.20.0",
@@ -1225,16 +1225,16 @@
       }
     },
     "node_modules/@dnd-kit/sortable": {
-      "version": "10.0.0",
-      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
-      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
+      "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
       "license": "MIT",
       "dependencies": {
         "@dnd-kit/utilities": "^3.2.2",
         "tslib": "^2.0.0"
       },
       "peerDependencies": {
-        "@dnd-kit/core": "^6.3.0",
+        "@dnd-kit/core": "^6.1.0",
         "react": ">=16.8.0"
       }
     },

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "llm-wiki",
   "private": true,
-  "version": "2.2.24",
+  "version": "2.2.25",
   "type": "module",
   "scripts": {
     "dev": "vite",
@@ -19,7 +19,7 @@
   "dependencies": {
     "@base-ui/react": "^1.3.0",
     "@dnd-kit/core": "^6.3.1",
-    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/sortable": "^8.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@fontsource-variable/geist": "^5.2.8",
     "@milkdown/kit": "^7.20.0",

+ 1 - 1
src-tauri/Cargo.lock

@@ -4442,7 +4442,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
 [[package]]
 name = "llm-wiki"
-version = "2.2.23"
+version = "2.2.25"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "llm-wiki"
-version = "2.2.23"
+version = "2.2.25"
 description = "LLM Wiki - A personal knowledge base for LLM concepts"
 authors = []
 edition = "2021"

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "2.2.24",
+  "version": "2.2.25",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 26 - 5
src/components/chat/chat-input-resize.spec.ts

@@ -1,15 +1,36 @@
 import { describe, expect, it } from "vitest"
-import { clampResizableInputHeight, resolveResizableInputMaxHeight } from "./chat-input-resize"
+import { clampResizableInputHeight, getResizeBoundsForElement, resolveMaxHeightFromContext, DEFAULT_RESIZABLE_INPUT_HEIGHT } from "./chat-input-resize"
 
 describe("chat input resize bounds", () => {
-  it("keeps the input height between the default height and half of the panel", () => {
+  it("keeps the input height between the default height and max height", () => {
     expect(clampResizableInputHeight(20, { minHeight: 44, maxHeight: 300 })).toBe(44)
     expect(clampResizableInputHeight(180, { minHeight: 44, maxHeight: 300 })).toBe(180)
     expect(clampResizableInputHeight(500, { minHeight: 44, maxHeight: 300 })).toBe(300)
   })
 
-  it("uses half of the available panel height as the maximum", () => {
-    expect(resolveResizableInputMaxHeight({ panelHeight: 900, viewportHeight: 1200 })).toBe(450)
-    expect(resolveResizableInputMaxHeight({ panelHeight: 0, viewportHeight: 1000 })).toBe(500)
+  it("clamps invalid finite values to min height", () => {
+    expect(clampResizableInputHeight(Number.NaN, { minHeight: 44, maxHeight: 300 })).toBe(44)
+    expect(clampResizableInputHeight(Number.POSITIVE_INFINITY, { minHeight: 44, maxHeight: 300 })).toBe(300)
+  })
+
+  it("returns safe defaults when element is null", () => {
+    const bounds = getResizeBoundsForElement(null)
+    expect(bounds.minHeight).toBe(DEFAULT_RESIZABLE_INPUT_HEIGHT)
+    expect(bounds.maxHeight).toBeGreaterThanOrEqual(DEFAULT_RESIZABLE_INPUT_HEIGHT)
+  })
+
+  it("predicts max height from context allowing upward expansion", () => {
+    const ctx = {
+      startRootTop: 680,
+      startRootHeight: 120,
+      startInputHeight: 44,
+      viewportHeight: 800,
+      containerHeight: 700,
+      fixedOverhead: 76,
+    }
+    const initialMax = resolveMaxHeightFromContext(ctx, 44)
+    expect(initialMax).toBeGreaterThan(44)
+    const expandedMax = resolveMaxHeightFromContext(ctx, 200)
+    expect(expandedMax).toBeGreaterThan(initialMax)
   })
 })

+ 85 - 22
src/components/chat/chat-input-resize.ts

@@ -1,34 +1,88 @@
 export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
-/** 输入框底部预留空间:发送按钮 + padding + 拖拽条 + 模型选择器等 */
 const INPUT_BOTTOM_RESERVED = 80
+const MIN_MESSAGE_AREA_MIN = 80
+const MIN_MESSAGE_AREA_RATIO = 0.15
 
 export interface ResizableInputBounds {
   minHeight: number
   maxHeight: number
 }
 
-export function resolveResizableInputMaxHeight({
-  panelHeight,
-  viewportHeight,
-}: {
-  panelHeight: number
+export interface ResizeContext {
+  startRootTop: number
+  startRootHeight: number
+  startInputHeight: number
   viewportHeight: number
-}): number {
-  const availableHeight = panelHeight > 0 ? panelHeight : viewportHeight
-  return Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(availableHeight / 2))
-}
-
-/**
- * 根据输入框在视口中的位置计算最大高度,防止向上拖动时超出软件界面下沿边界。
- * @param inputTopOffset 输入框顶部距视口顶部的距离(通过 getBoundingClientRect().top 获取)
- * @param viewportHeight 视口高度(window.innerHeight)
- */
-export function resolveViewportAwareMaxHeight(
-  inputTopOffset: number,
-  viewportHeight: number,
-): number {
-  const availableSpace = viewportHeight - inputTopOffset - INPUT_BOTTOM_RESERVED
-  return Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(availableSpace))
+  containerHeight: number
+  fixedOverhead: number
+}
+
+function computeMinMessageAreaHeight(containerHeight: number): number {
+  return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
+}
+
+export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
+  if (!rootEl) return null
+  const rootRect = rootEl.getBoundingClientRect()
+  const containerEl = findChatContainer(rootEl)
+  const containerRect = containerEl?.getBoundingClientRect()
+  const containerHeight = containerRect?.height ?? window.innerHeight
+  const textareaEl = rootEl.querySelector("textarea")
+  const textareaRect = textareaEl?.getBoundingClientRect()
+  const actualTextareaHeight = textareaRect?.height ?? currentInputHeight
+  const fixedOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
+  return {
+    startRootTop: rootRect.top,
+    startRootHeight: rootRect.height,
+    startInputHeight: currentInputHeight,
+    viewportHeight: window.innerHeight,
+    containerHeight,
+    fixedOverhead,
+  }
+}
+
+function findChatContainer(root: HTMLDivElement): HTMLElement | null {
+  let current: HTMLElement | null = root.parentElement
+  for (let i = 0; i < 6 && current; i++) {
+    const style = window.getComputedStyle(current)
+    if (style.display === "flex" && style.flexDirection === "column" && current.classList.contains("overflow-hidden")) {
+      return current
+    }
+    current = current.parentElement
+  }
+  return root.parentElement
+}
+
+export function resolveMaxHeightFromContext(ctx: ResizeContext, nextHeight: number): number {
+  const predictedRootTop = ctx.startRootTop - (nextHeight - ctx.startInputHeight)
+  const viewportAvailable = ctx.viewportHeight - predictedRootTop - INPUT_BOTTOM_RESERVED
+  const minMessageArea = computeMinMessageAreaHeight(ctx.containerHeight)
+  const containerAvailable = ctx.containerHeight - minMessageArea - ctx.fixedOverhead
+  const effectiveMax = Math.min(
+    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(viewportAvailable)),
+    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerAvailable)),
+  )
+  return effectiveMax
+}
+
+export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): ResizableInputBounds {
+  if (!rootEl) {
+    return { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight: 200 }
+  }
+  const containerEl = findChatContainer(rootEl)
+  const containerRect = containerEl?.getBoundingClientRect()
+  const containerHeight = containerRect?.height ?? window.innerHeight
+  const rootRect = rootEl.getBoundingClientRect()
+  const textareaEl = rootEl.querySelector("textarea")
+  const textareaHeight = textareaEl?.getBoundingClientRect().height ?? DEFAULT_RESIZABLE_INPUT_HEIGHT
+  const fixedOverhead = Math.max(0, rootRect.height - textareaHeight)
+  const minMessageArea = computeMinMessageAreaHeight(containerHeight)
+  const containerMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerHeight - minMessageArea - fixedOverhead))
+  const viewportMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(window.innerHeight - rootRect.top - INPUT_BOTTOM_RESERVED))
+  return {
+    minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
+    maxHeight: Math.min(containerMax, viewportMax),
+  }
 }
 
 export function clampResizableInputHeight(
@@ -40,3 +94,12 @@ export function clampResizableInputHeight(
   if (!Number.isFinite(nextHeight)) return minHeight
   return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
 }
+
+export function isHeightAtMax(currentHeight: number, bounds: ResizableInputBounds): boolean {
+  const maxHeight = Math.max(bounds.minHeight, Math.floor(bounds.maxHeight))
+  return currentHeight >= maxHeight - 1
+}
+
+export function isHeightAtMin(currentHeight: number, bounds: ResizableInputBounds): boolean {
+  return currentHeight <= bounds.minHeight + 1
+}

+ 167 - 39
src/components/chat/chat-input.tsx

@@ -1,4 +1,4 @@
-import { useRef, useState, useCallback, type ReactNode } from "react"
+import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"
 import { Send, Square } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { isImeComposing } from "@/lib/keyboard-utils"
@@ -6,9 +6,30 @@ import { useChatStore } from "@/stores/chat-store"
 import {
   clampResizableInputHeight,
   DEFAULT_RESIZABLE_INPUT_HEIGHT,
-  resolveViewportAwareMaxHeight,
+  getResizeBoundsForElement,
+  createResizeContext,
+  resolveMaxHeightFromContext,
+  isHeightAtMax,
+  isHeightAtMin,
+  type ResizeContext,
+  type ResizableInputBounds,
 } from "./chat-input-resize"
 
+const CHAT_INPUT_HEIGHT_KEY = "lk-chat-input-height"
+
+function loadSavedInputHeight(): number | null {
+  if (typeof localStorage === "undefined") return null
+  const raw = localStorage.getItem(CHAT_INPUT_HEIGHT_KEY)
+  if (!raw) return null
+  const v = Number(raw)
+  return Number.isFinite(v) && v >= DEFAULT_RESIZABLE_INPUT_HEIGHT ? Math.round(v) : null
+}
+
+function saveInputHeight(h: number) {
+  if (typeof localStorage === "undefined") return
+  localStorage.setItem(CHAT_INPUT_HEIGHT_KEY, String(Math.round(h)))
+}
+
 interface ChatInputProps {
   onSend: (text: string) => void
   onStop: () => void
@@ -21,17 +42,6 @@ interface ChatInputProps {
   onChange?: (value: string) => void
 }
 
-function resolveResizePanelHeight(root: HTMLDivElement | null): number {
-  let current = root?.parentElement ?? null
-  let panelHeight = 0
-  while (current) {
-    const height = current.getBoundingClientRect().height
-    if (Number.isFinite(height)) panelHeight = Math.max(panelHeight, height)
-    current = current.parentElement
-  }
-  return panelHeight
-}
-
 export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingControls, footerControls, inlineSendButton = true, value: controlledValue, onChange }: ChatInputProps) {
   const activeConversationId = useChatStore((state) => state.activeConversationId)
   const setConversationInputDraft = useChatStore((state) => state.setConversationInputDraft)
@@ -45,9 +55,74 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
   const storeValue = conversation?.inputDraft ?? ""
   const value = isControlled ? controlledValue : activeConversationId ? storeValue : fallbackDraft
 
-  const [inputHeight, setInputHeight] = useState(DEFAULT_RESIZABLE_INPUT_HEIGHT)
+  const savedHeight = useRef<number | null>(loadSavedInputHeight())
+  const [inputHeight, setInputHeight] = useState<number>(() => savedHeight.current ?? DEFAULT_RESIZABLE_INPUT_HEIGHT)
+  const [userSetMinHeight, setUserSetMinHeight] = useState<number | null>(savedHeight.current)
+  const userSetMinHeightRef = useRef<number | null>(savedHeight.current)
+  const [isDragging, setIsDragging] = useState(false)
+  const [isAtLimitTop, setIsAtLimitTop] = useState(false)
+  const [isAtLimitBottom, setIsAtLimitBottom] = useState(true)
   const rootRef = useRef<HTMLDivElement>(null)
   const textareaRef = useRef<HTMLTextAreaElement>(null)
+  const resizeContextRef = useRef<ResizeContext | null>(null)
+  const dragHeightRef = useRef<number>(inputHeight)
+  const inputHeightRef = useRef<number>(inputHeight)
+
+  useEffect(() => {
+    inputHeightRef.current = inputHeight
+  }, [inputHeight])
+
+  const updateLimitState = useCallback((height: number) => {
+    const bounds = getResizeBoundsForElement(rootRef.current)
+    setIsAtLimitTop(isHeightAtMax(height, bounds))
+    setIsAtLimitBottom(isHeightAtMin(height, bounds))
+  }, [])
+
+  useEffect(() => {
+    updateLimitState(inputHeight)
+  }, [inputHeight, updateLimitState])
+
+  useEffect(() => {
+    userSetMinHeightRef.current = userSetMinHeight
+  }, [userSetMinHeight])
+
+  useEffect(() => {
+    const handleResize = () => {
+      const ta = textareaRef.current
+      const bounds = getResizeBoundsForElement(rootRef.current)
+      const minBase = userSetMinHeightRef.current ?? DEFAULT_RESIZABLE_INPUT_HEIGHT
+      if (ta) {
+        ta.style.height = "auto"
+        const contentHeight = ta.scrollHeight
+        const current = inputHeightRef.current
+        const next = clampResizableInputHeight(Math.max(minBase, contentHeight, current), bounds)
+        ta.style.height = `${next}px`
+        setInputHeight(next)
+      } else {
+        setInputHeight((prev) => clampResizableInputHeight(prev, bounds))
+      }
+    }
+    window.addEventListener("resize", handleResize)
+    return () => window.removeEventListener("resize", handleResize)
+  }, [])
+
+  const autoFitTextarea = useCallback(() => {
+    const ta = textareaRef.current
+    if (!ta) return
+    const bounds = getResizeBoundsForElement(rootRef.current)
+    const minBase = userSetMinHeightRef.current ?? DEFAULT_RESIZABLE_INPUT_HEIGHT
+    ta.style.height = "auto"
+    const contentHeight = ta.scrollHeight
+    const next = clampResizableInputHeight(Math.max(minBase, contentHeight), bounds)
+    ta.style.height = `${next}px`
+    setInputHeight(next)
+    setIsAtLimitTop(isHeightAtMax(next, bounds))
+    setIsAtLimitBottom(isHeightAtMin(next, bounds))
+  }, [])
+
+  useEffect(() => {
+    autoFitTextarea()
+  }, [value, autoFitTextarea])
 
   const setValue = useCallback(
     (draft: string) => {
@@ -62,27 +137,30 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
     [isControlled, onChange, activeConversationId, setConversationInputDraft]
   )
 
-  const getResizeBounds = useCallback(() => {
-    const rootEl = rootRef.current
-    const inputTopOffset = rootEl?.getBoundingClientRect().top ?? 0
-    const viewportHeight = window.innerHeight
-    // 优先使用视口边界感知的最大高度,防止输入框下沿超出软件界面
-    const viewportMax = resolveViewportAwareMaxHeight(inputTopOffset, viewportHeight)
-    const panelHeight = resolveResizePanelHeight(rootEl)
-    const panelMax = panelHeight > 0 ? Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(panelHeight / 2)) : viewportMax
-    return {
-      minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
-      maxHeight: Math.min(viewportMax, panelMax),
-    }
+  const getStaticBounds = useCallback(() => {
+    return getResizeBoundsForElement(rootRef.current)
   }, [])
 
   const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
     setValue(e.target.value)
-    const ta = e.target
-    if (ta.scrollHeight > inputHeight) {
-      setInputHeight(clampResizableInputHeight(ta.scrollHeight, getResizeBounds()))
+    requestAnimationFrame(() => {
+      autoFitTextarea()
+    })
+  }, [setValue, autoFitTextarea])
+
+  const resetHeight = useCallback(() => {
+    setUserSetMinHeight(null)
+    const ta = textareaRef.current
+    const bounds = getResizeBoundsForElement(rootRef.current)
+    if (ta) {
+      ta.style.height = "auto"
+      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, ta.scrollHeight), bounds)
+      ta.style.height = `${next}px`
+      setInputHeight(next)
+    } else {
+      setInputHeight(DEFAULT_RESIZABLE_INPUT_HEIGHT)
     }
-  }, [getResizeBounds, inputHeight, setValue])
+  }, [])
 
   const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
     if (event.button !== 0) return
@@ -94,6 +172,12 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
     const startHeight = inputHeight
     const previousCursor = document.body.style.cursor
     document.body.style.cursor = "ns-resize"
+    setIsDragging(true)
+
+    const ctx = createResizeContext(rootRef.current, startHeight)
+    resizeContextRef.current = ctx
+    dragHeightRef.current = startHeight
+
     try {
       resizeHandle.setPointerCapture(pointerId)
     } catch {
@@ -102,9 +186,30 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
 
     const handlePointerMove = (pointerEvent: PointerEvent) => {
       const nextHeight = startHeight + (startY - pointerEvent.clientY)
-      setInputHeight(clampResizableInputHeight(nextHeight, getResizeBounds()))
+      let maxHeight: number
+      let bounds: ResizableInputBounds
+      if (resizeContextRef.current) {
+        maxHeight = resolveMaxHeightFromContext(resizeContextRef.current, nextHeight)
+        bounds = { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight }
+      } else {
+        bounds = getStaticBounds()
+      }
+      const clamped = clampResizableInputHeight(nextHeight, bounds)
+      dragHeightRef.current = clamped
+      const ta = textareaRef.current
+      if (ta) {
+        ta.style.height = `${clamped}px`
+      }
+      setInputHeight(clamped)
+      setIsAtLimitTop(isHeightAtMax(clamped, bounds))
+      setIsAtLimitBottom(isHeightAtMin(clamped, bounds))
     }
     const handlePointerUp = () => {
+      const finalHeight = dragHeightRef.current
+      setUserSetMinHeight(finalHeight)
+      saveInputHeight(finalHeight)
+      resizeContextRef.current = null
+      setIsDragging(false)
       try {
         resizeHandle.releasePointerCapture(pointerId)
       } catch {
@@ -119,21 +224,27 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
     window.addEventListener("pointermove", handlePointerMove)
     window.addEventListener("pointerup", handlePointerUp)
     window.addEventListener("pointercancel", handlePointerUp)
-  }, [getResizeBounds, inputHeight])
+  }, [inputHeight, getStaticBounds])
+
+  const handleDoubleClick = useCallback(() => {
+    resetHeight()
+  }, [resetHeight])
 
   const handleSend = useCallback(() => {
     const trimmed = value.trim()
     if (!trimmed || isStreaming) return
     onSend(trimmed)
     setValue("")
+    setUserSetMinHeight(null)
+    setInputHeight(DEFAULT_RESIZABLE_INPUT_HEIGHT)
+    const ta = textareaRef.current
+    if (ta) {
+      ta.style.height = `${DEFAULT_RESIZABLE_INPUT_HEIGHT}px`
+    }
   }, [value, isStreaming, onSend, setValue])
 
   const handleKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-      // Don't submit on the Enter that commits an IME candidate —
-      // the user is mid-composition (Chinese / Japanese / Korean
-      // input method picking an English word or phrase) and would
-      // see the message fire before they finished typing.
       if (isImeComposing(e)) return
       if (e.key === "Enter" && !e.shiftKey) {
         e.preventDefault()
@@ -143,16 +254,33 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leadingCon
     [handleSend],
   )
 
+  const handleBarColor = isDragging
+    ? isAtLimitTop
+      ? "bg-destructive/60"
+      : "bg-primary/60"
+    : isAtLimitTop
+      ? "bg-destructive/30"
+      : isAtLimitBottom
+        ? "bg-border"
+        : "bg-primary/30"
+
+  const resizeTitle = isAtLimitBottom
+    ? "向上拖动拉高输入框,双击重置高度"
+    : isAtLimitTop
+      ? "已达最大高度,向下拖动缩小,双击重置"
+      : "拖动调整输入框高度,双击重置"
+
   return (
     <div ref={rootRef} className="border-t">
       <div
         role="separator"
         aria-label="拖动调整输入框高度"
-        title="拖动调整输入框高度"
-        className="flex h-2 cursor-ns-resize items-center justify-center"
+        title={resizeTitle}
+        className="flex h-2 cursor-ns-resize items-center justify-center transition-colors"
         onPointerDown={handleResizePointerDown}
+        onDoubleClick={handleDoubleClick}
       >
-        <span className="h-0.5 w-10 rounded-full bg-border" />
+        <span className={`h-0.5 w-10 rounded-full transition-colors ${handleBarColor}`} />
       </div>
       {leadingControls ? (
         <div className="px-3 pb-2">