feat(chat): replace single-line input with resizable multiline textarea

- Replaced <Input> with auto-growing <textarea> that expands as you type
- Supports multiline input: Enter inserts newline, Ctrl+Enter sends
- Single-line mode preserved: if no newlines yet, Enter still sends
- Expand/collapse button (Maximize2/Minimize2) toggles between 4-line
  max (collapsed) and 16-line max (expanded) views
- Markdown / Plain-text toggle: #MD (monospace font, markdown paste
  preserves formatting) vs T TXT (sans-serif, plain text)
- Smart paste in markdown mode: intercepts clipboard to preserve raw
  markdown text instead of browser's rich-text conversion
- Line counter badge (e.g. '15L MD') shown when multiline
- Ctrl hint label under send button for discoverability
- Placeholder updated: 'Введите команду или вопрос… (Ctrl+Enter отправить)'
- Textarea resets height after message is sent
- Removed unused Input import
This commit is contained in:
bboxwtf
2026-03-22 16:59:33 +00:00
parent 13b7ab57c5
commit e4666a95bc

View File

@@ -14,7 +14,7 @@
* - Background: SSE request continues even when user navigates away,
* because all state lives in chatStore (module-level singleton).
*/
import { useState, useRef, useEffect, useCallback, useSyncExternalStore } from "react";
import { useState, useRef, useEffect, useCallback, useSyncExternalStore, useMemo } from "react";
import { Streamdown } from "streamdown";
import { motion, AnimatePresence } from "framer-motion";
import { trpc } from "@/lib/trpc";
@@ -22,7 +22,6 @@ import { chatStore, type ChatMessage, type Conversation, type ConsoleEntry, type
import TaskBoard from "@/components/TaskBoard";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -55,6 +54,12 @@ import {
RefreshCw,
ListTodo,
BarChart3,
Maximize2,
Minimize2,
Type,
Hash,
GripHorizontal,
CornerDownLeft,
} from "lucide-react";
// ─── useChatStore hook ────────────────────────────────────────────────────────
@@ -365,7 +370,12 @@ export default function Chat() {
const [rightTab, setRightTab] = useState<"console" | "tasks">("console");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [inputMode, setInputMode] = useState<"plain" | "markdown">("markdown");
const [isExpanded, setIsExpanded] = useState(false);
const MIN_ROWS = 1;
const MAX_ROWS_COLLAPSED = 4;
const MAX_ROWS_EXPANDED = 16;
// ── Remote data ───────────────────────────────────────────────────────────
const agentsQuery = trpc.agents.list.useQuery(undefined, { refetchInterval: 30000 });
@@ -390,20 +400,68 @@ export default function Chat() {
}
}, [active?.messages, isThinking]);
// Focus input when not thinking
// Focus textarea when not thinking
useEffect(() => {
if (!isThinking) {
setTimeout(() => inputRef.current?.focus(), 50);
setTimeout(() => textareaRef.current?.focus(), 50);
}
}, [isThinking]);
// Auto-resize textarea
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const lineHeight = 20; // approx line height in px
const maxRows = isExpanded ? MAX_ROWS_EXPANDED : MAX_ROWS_COLLAPSED;
const maxH = lineHeight * maxRows;
const minH = lineHeight * MIN_ROWS;
const scrollH = el.scrollHeight;
el.style.height = `${Math.max(minH, Math.min(scrollH, maxH))}px`;
}, [isExpanded]);
useEffect(() => {
autoResize();
}, [input, autoResize]);
const sendMessage = useCallback(() => {
if (!input.trim() || isThinking) return;
const text = input.trim();
setInput("");
setIsExpanded(false);
// Reset textarea height after send
if (textareaRef.current) textareaRef.current.style.height = "auto";
chatStore.send(text, activeId);
}, [input, isThinking, activeId]);
const handleTextareaKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl+Enter or Cmd+Enter to send
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
sendMessage();
return;
}
// Plain Enter in collapsed single-line mode also sends (like before)
// But if expanded or multiline content, Enter inserts newline
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
const hasMultipleLines = input.includes("\n");
if (!isExpanded && !hasMultipleLines) {
e.preventDefault();
sendMessage();
}
// else: let default newline happen
}
},
[sendMessage, isExpanded, input]
);
// Line count for display
const lineCount = useMemo(() => {
if (!input) return 0;
return input.split("\n").length;
}, [input]);
const orchConfig = orchestratorConfigQuery.data;
const agents = agentsQuery.data ?? [];
const activeAgents = agents.filter((a) => a.isActive && !(a as any).isOrchestrator);
@@ -552,48 +610,125 @@ export default function Chat() {
</div>
{/* Input bar */}
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
{/* Quick commands */}
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
{[
"Список агентов",
"Покажи файлы проекта",
"Статус Docker",
"Создай инструмент",
].map((cmd) => (
<div className="border-t border-border/50 bg-secondary/10 shrink-0">
{/* Toolbar row: quick commands + mode toggles */}
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<div className="flex items-center gap-1.5 flex-wrap">
{[
"Список агентов",
"Покажи файлы проекта",
"Статус Docker",
"Создай инструмент",
].map((cmd) => (
<button
key={cmd}
onClick={() => setInput(cmd)}
disabled={isThinking}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20 disabled:opacity-40"
>
{cmd}
</button>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Plain / Markdown toggle */}
<button
key={cmd}
onClick={() => setInput(cmd)}
disabled={isThinking}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20 disabled:opacity-40"
onClick={() => setInputMode(inputMode === "plain" ? "markdown" : "plain")}
className={`p-1 rounded text-[9px] font-mono flex items-center gap-0.5 transition-colors border ${
inputMode === "markdown"
? "border-cyan-500/30 text-cyan-400 bg-cyan-500/10"
: "border-border/30 text-muted-foreground/50 hover:text-muted-foreground"
}`}
title={inputMode === "markdown" ? "Markdown mode (вставка markdown сохраняется)" : "Plain text mode"}
>
{cmd}
{inputMode === "markdown"
? <><Hash className="w-3 h-3" /><span className="hidden sm:inline">MD</span></>
: <><Type className="w-3 h-3" /><span className="hidden sm:inline">TXT</span></>
}
</button>
))}
{/* Expand / collapse */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 rounded border border-border/30 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
title={isExpanded ? "Свернуть" : "Развернуть"}
>
{isExpanded
? <Minimize2 className="w-3 h-3" />
: <Maximize2 className="w-3 h-3" />
}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-mono text-sm shrink-0">$</span>
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
placeholder={isThinking ? "Фоновая обработка… (можете перезагрузить страницу)" : "Введите команду или вопрос…"}
disabled={isThinking}
className="bg-transparent border-none text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0 h-8"
/>
<Button
size="sm"
onClick={sendMessage}
disabled={isThinking || !input.trim()}
className="bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 h-8 w-8 p-0 shrink-0"
>
{isThinking
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Send className="w-3.5 h-3.5" />
}
</Button>
{/* Textarea + send */}
<div className="flex items-end gap-2 px-3 pb-2">
<span className="text-cyan-400 font-mono text-sm shrink-0 pb-1.5">$</span>
<div className="flex-1 min-w-0 relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleTextareaKeyDown}
onPaste={(e) => {
// In markdown mode, preserve pasted formatting as-is
if (inputMode === "markdown") {
// Default paste behavior works fine for plain text clipboard
// For rich text, we prefer text/plain
const plain = e.clipboardData.getData("text/plain");
if (plain) {
e.preventDefault();
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = input.slice(0, start);
const after = input.slice(end);
setInput(before + plain + after);
// Set cursor after pasted text
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + plain.length;
});
}
}
}}
placeholder={isThinking ? "Фоновая обработка… (можете перезагрузить страницу)" : "Введите команду или вопрос… (Ctrl+Enter отправить)"}
disabled={isThinking}
rows={1}
className={`w-full bg-transparent border-none text-foreground text-sm placeholder:text-muted-foreground/50 focus:outline-none focus:ring-0 resize-none overflow-y-auto leading-5 py-1.5 ${
inputMode === "markdown" ? "font-mono" : "font-sans"
}`}
style={{
minHeight: "20px",
maxHeight: isExpanded ? "320px" : "80px",
}}
/>
{/* Line count & mode indicator */}
{(lineCount > 1 || isExpanded) && (
<div className="absolute right-0 bottom-0 flex items-center gap-1.5 text-[8px] font-mono text-muted-foreground/30 pointer-events-none pr-0.5 pb-0.5">
{lineCount > 0 && <span>{lineCount}L</span>}
<span>{inputMode === "markdown" ? "MD" : "TXT"}</span>
</div>
)}
</div>
<div className="flex flex-col items-center gap-1 shrink-0 pb-0.5">
<Button
size="sm"
onClick={sendMessage}
disabled={isThinking || !input.trim()}
className="bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 h-8 w-8 p-0"
title="Ctrl+Enter"
>
{isThinking
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Send className="w-3.5 h-3.5" />
}
</Button>
<span className="text-[7px] font-mono text-muted-foreground/30 flex items-center gap-0.5">
<CornerDownLeft className="w-2 h-2" />Ctrl
</span>
</div>
</div>
</div>
</CardContent>