diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index 820c16c..7fb8a3b 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LocalPrStatus, @@ -7,8 +6,9 @@ import type { StoredPullRequest, } from '@meebox/shared'; import { AutoReviewIcon, SendIcon, StopIcon } from '../../../common'; -import { COMMANDS, type CommandSpec } from '../commands'; -import { loadChatHistory, pushChatHistory } from '../utils/chat-history'; +import { COMMANDS } from '../commands'; +import { useChatInput } from '../hooks/useChatInput'; +import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; interface ChatInputBarProps { pr: StoredPullRequest | null; @@ -37,16 +37,11 @@ interface ChatInputBarProps { } /** - * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。 + * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。状态机(输入 / 命令解析 / 补全 / 历史回放 / + * 停止)见 [useChatInput](../hooks/useChatInput.ts);命令解析纯逻辑见 ../utils/parse-command。 * - * 提交语义 (按 Enter 或点发送): - * - 空输入 → 不提交 - * - `/describe` / `/review` 开头 → 触发对应工具,忽略后面文字 - * - `/ask <文本>` 开头 → 触发 ask,rest 作 question - * - `/xxx` 但 xxx 未知 → 报错提示 - * - 不以 `/` 开头 → 等价于 `/ask <整段>` - * - * Shift+Enter 换行,Enter 提交。textarea 高度 1→5 行自适应,超过 5 行内部滚动。 + * 提交语义:空不提交;`/describe` `/review` 等触发对应工具;`/ask <文本>` 触发 ask;未知 `/xxx` 报错; + * 不以 `/` 开头 = 自然语言委派给自由规划 Agent。Shift+Enter 换行,Enter 提交。 */ export function ChatInputBar({ pr, @@ -61,271 +56,38 @@ export function ChatInputBar({ onAgentReview, }: ChatInputBarProps) { const { t } = useTranslation(); - const [input, setInput] = useState(''); - const [parseError, setParseError] = useState(null); - // PR 切换时清掉异常提示 + 输入框残留 (避免跨 PR 显示陈旧的错误"未知命令" 等) - useEffect(() => { - setParseError(null); - setInput(''); - }, [pr?.localId]); - const [cmdMenuOpen, setCmdMenuOpen] = useState(false); - // 自动补全菜单选中项索引 (textarea 输入 / 时显示的浮层) - const [autocompleteIdx, setAutocompleteIdx] = useState(0); - // 已经为某个特定输入值关闭过菜单 (Esc / 选中后插入)。input 一变就失效 - // → 用户继续打字时菜单会自然重新出现,但选中 / Esc 后不会立刻重弹 - const [dismissedFor, setDismissedFor] = useState(null); - // 历史回放:从最新到最老的栈;historyIdx 表示当前正在浏览的位置 (-1 = 不在浏览态) - const [history, setHistory] = useState(() => loadChatHistory()); - const [historyIdx, setHistoryIdx] = useState(-1); - // 进入历史浏览前用户正在编辑的内容;按 Down 回到底端时还原回去,模仿 shell 行为 - const draftBeforeHistoryRef = useRef(''); - const textareaRef = useRef(null); - const cmdMenuRef = useRef(null); - - // 队列模型:仅 !pr / pr-agent 未就绪 时禁用 input。activeRun / busyOnOtherPr - // 不再阻塞新提交 (会排队 by main)。running 决定是否渲染 stop 按钮:除活动工具 run 外, - // Agent 自身执行阶段(思考 / 编排,无工具 run 占用)也算「运行中」,以便随时取消。 - const running = runningTool !== null || agentRunningHere; - // LLM 未配置时一并禁用:即便 pr-agent 运行时就绪,没有模型也无法发起调用 - const disabled = !pr || !prAgent.available || !llmConfigured; - // stop 按钮点过后等 main 回 queueChanged 才会改变状态;中间这段时间二次点击 - // 应失效,避免反复 spam abort - const [stopRequested, setStopRequested] = useState(false); - // running → false 时 (run 结束了) 重置 stopRequested,下次起 run 又能取消 - useEffect(() => { - if (!running) setStopRequested(false); - }, [running]); - const trimmed = input.trim(); - // `/` 开头 + 命令名还没敲完整 (没空格) → 显示候选;已为当前 input dismiss 过则隐藏 - const showAutocomplete = - !disabled && dismissedFor !== input && input.startsWith('/') && !input.includes(' '); - const filtered = showAutocomplete - ? COMMANDS.filter((c) => c.label.startsWith(input.split(' ')[0] ?? '')) - : []; - - // 输入变化时重置选中项到首条 (候选集变了) - useEffect(() => { - setAutocompleteIdx(0); - }, [input]); - - // `/` 命令按钮触发的弹出菜单:点击外部 / Esc / 选中命令时关闭 - useEffect(() => { - if (!cmdMenuOpen) return; - const onDown = (e: MouseEvent): void => { - if (!cmdMenuRef.current?.contains(e.target as Node)) { - setCmdMenuOpen(false); - } - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') setCmdMenuOpen(false); - }; - document.addEventListener('mousedown', onDown); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - document.removeEventListener('keydown', onKey); - }; - }, [cmdMenuOpen]); - - // textarea 高度:用户拖顶边 handle 调整。 - // - // 不用 CSS `resize: vertical` 因为它的 handle 在右下角、向下拖才放大 —— - // 但 input 整体被钉在 chat 面板底部,视觉上 textarea 是"向上扩展",跟操作方向 - // 反直觉。改成顶边自绘 handle (类似 chat-pane-resize-handle 模式),向上拖 = 放大, - // 视觉操作直觉一致。 - // - // 边界跟 css 里 min-height (2 行) / max-height (5 行) 一致;state null 时不写 - // inline style,由 css 默认值起手 - const [textareaHeightPx, setTextareaHeightPx] = useState(null); - const handleTextareaResizeStart = (e: React.MouseEvent): void => { - e.preventDefault(); - const el = textareaRef.current; - if (!el) return; - const startY = e.clientY; - const startHeight = el.getBoundingClientRect().height; - // 跟 css token: $fs-md=13 * $lh-normal=1.4 = 18.2 px/line;$space-3=6 px padding 上下 = 12 px - const MIN = Math.round(13 * 1.4 * 2 + 12); - const MAX = Math.round(13 * 1.4 * 5 + 12); - const onMove = (ev: MouseEvent): void => { - // 上拖 dy < 0 → 高度增加;下拖反之 - const dy = ev.clientY - startY; - const next = Math.min(MAX, Math.max(MIN, startHeight - dy)); - setTextareaHeightPx(next); - }; - const onUp = (): void => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - }; - - const handleInsertCommand = (cmd: CommandSpec): void => { - setInput(cmd.insertAs); - setParseError(null); - setCmdMenuOpen(false); - // 选中后立即关掉补全菜单 (insertAs 可能 "/describe" 没空格,否则会一直撑着)。 - // dismissedFor 绑当前 input 值,用户继续打字 input 变了菜单会重新打开 - setDismissedFor(cmd.insertAs); - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(cmd.insertAs.length, cmd.insertAs.length); - }); - } - }; - - const submit = (): void => { - if (disabled || !trimmed) return; - setParseError(null); - // 解析命令头:'/' 起手 → COMMANDS 表里找;无 '/' → 等价 /ask <整段> - let cmd: CommandSpec; - let rest = ''; - if (trimmed.startsWith('/')) { - const spaceIdx = trimmed.indexOf(' '); - const head = spaceIdx < 0 ? trimmed : trimmed.slice(0, spaceIdx); - rest = spaceIdx < 0 ? '' : trimmed.slice(spaceIdx + 1).trim(); - const found = COMMANDS.find((c) => c.label === head); - if (!found) { - setParseError( - t('chatPane.unknownCommand', { - head, - cmds: COMMANDS.map((c) => c.label).join(' / '), - }), - ); - return; - } - cmd = found; - } else { - // 无 '/' → 自然语言「对话即委派」:交给自由规划 Agent(而非 /ask)。 - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onAgentAsk(trimmed); - return; - } - // review-action:/approve /needswork 没有参数,多余文本拒绝以免误用 - if (cmd.kind === 'review-action') { - if (rest) { - setParseError(t('chatPane.commandNoArgs', { cmd: cmd.label })); - return; - } - if (!onSetReviewStatus) return; // 没装回调直接忽略 (保护性) - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onSetReviewStatus(cmd.reviewStatus); - return; - } - // pragent:/ask 必须带问题,其他工具空 question - let question: string | undefined; - if (cmd.name === 'ask') { - if (!rest) { - setParseError(t('chatPane.askNeedsQuestion')); - return; - } - question = rest; - } - setHistory(pushChatHistory(input)); - setHistoryIdx(-1); - draftBeforeHistoryRef.current = ''; - setInput(''); - onRun(cmd.name, question); - }; - - // 历史回放工具:根据 idx 设 textarea 内容;idx = -1 表示退出浏览态,恢复 draft - const applyHistoryIdx = (nextIdx: number): void => { - setHistoryIdx(nextIdx); - setInput(nextIdx < 0 ? draftBeforeHistoryRef.current : (history[nextIdx] ?? '')); - // 光标移到末尾,下一次 Up/Down 行为可预期 - const el = textareaRef.current; - if (el) { - requestAnimationFrame(() => { - el.focus(); - const len = el.value.length; - el.setSelectionRange(len, len); - }); - } - }; - - // 判断是否应让 Up/Down 触发历史回放:textarea 光标必须在首行 / 末行边缘, - // 否则让 Up/Down 走原生光标移动 (多行编辑时还在行内导航不能被劫持) - const atFirstLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(0, el.selectionStart).indexOf('\n') < 0; - }; - const atLastLine = (): boolean => { - const el = textareaRef.current; - if (!el) return false; - return el.value.slice(el.selectionEnd).indexOf('\n') < 0; - }; - - const onKeyDown = (e: React.KeyboardEvent): void => { - // 输入法 composing 中:所有快捷键都不拦截,交给 IME 处理 - if (e.nativeEvent.isComposing) return; - - // 自动补全菜单打开时:拦截 Up/Down/Enter/Tab/Esc 用于菜单导航,避免落到 textarea - if (showAutocomplete && filtered.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setAutocompleteIdx((i) => (i + 1) % filtered.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setAutocompleteIdx((i) => (i - 1 + filtered.length) % filtered.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const cmd = filtered[Math.min(autocompleteIdx, filtered.length - 1)]; - if (cmd) handleInsertCommand(cmd); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setDismissedFor(input); - return; - } - } - - // 历史回放:菜单未打开时,Up/Down 在边缘行 → 翻历史。中间行让原生光标移动接管 - if (e.key === 'ArrowUp' && history.length > 0 && atFirstLine()) { - e.preventDefault(); - if (historyIdx < 0) { - // 首次进浏览态:把当前编辑内容存为 draft,方便 Down 回到底端时复原 - draftBeforeHistoryRef.current = input; - } - applyHistoryIdx(Math.min(historyIdx + 1, history.length - 1)); - return; - } - if (e.key === 'ArrowDown' && historyIdx >= 0 && atLastLine()) { - e.preventDefault(); - applyHistoryIdx(historyIdx - 1); - return; - } - - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - submit(); - } - }; - - const placeholder = !prAgent.available - ? t('chatPane.placeholderNotReady') - : !llmConfigured - ? t('chatPane.placeholderNeedLlm') - : !pr - ? t('chatPane.placeholderNoPr') - : t('chatPane.placeholderReady'); + const { + input, + setInput, + parseError, + disabled, + running, + stopRequested, + requestStop, + showAutocomplete, + filtered, + autocompleteIdx, + setAutocompleteIdx, + cmdMenuOpen, + setCmdMenuOpen, + handleInsertCommand, + onKeyDown, + submit, + placeholder, + textareaRef, + cmdMenuRef, + } = useChatInput({ + pr, + prAgent, + llmConfigured, + runningTool, + agentRunningHere, + onRun, + onAgentAsk, + onCancel, + onSetReviewStatus, + }); + const { textareaHeightPx, handleTextareaResizeStart } = useTextareaAutosizeDrag(textareaRef); return (
{ - if (stopRequested) return; - setStopRequested(true); - onCancel(); - }} + onClick={requestStop} disabled={stopRequested} title={t('chatPane.stopTitle')} aria-label={t('chatPane.stopAria')} @@ -466,7 +224,7 @@ export function ChatInputBar({