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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user