Commit 9bdbdc7a authored by Administrator's avatar Administrator

Update frontend/src/components/ChatView.jsx via Son of Anton

parent 45b0db20
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp, usePermissions } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api"; import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, listLinkedRepos, getRepoBranches, commitFromChat,
} from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, GitBranch, RefreshCw, Globe, Presentation, FileOutput, Wand2, ChevronDown, Paintbrush } from "lucide-react"; import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText,
Loader2, GitBranch, GitCommitHorizontal, Globe, Check, AlertCircle,
RefreshCw,
} from "lucide-react";
const ALL_MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" }, { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
]; ];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
const UI_DESIGN_PREFIX = `[UI DESIGN MODE] You are now a world-class UI/UX designer. Generate COMPLETE, SELF-CONTAINED HTML files that render beautifully in a browser preview.\n\nCRITICAL RULES:\n- Output a SINGLE HTML file using the format \`\`\`html:design.html with ALL CSS and JS embedded inline\n- Include <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n- Use modern CSS (flexbox, grid, custom properties, transitions, animations)\n- Make it fully responsive (mobile-first)\n- Use realistic, professional content (NOT \"Lorem ipsum\")\n- Include hover states, focus states, and micro-interactions\n- Use a cohesive color palette and typography (Google Fonts via CDN OK)\n- If the design uses Tailwind-like utilities, include the Tailwind CDN script\n- For multi-screen apps, generate separate HTML files per screen\n- The output MUST look production-quality and polished\n- Include subtle shadows, gradients, and modern design patterns\n\n`; function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function classifyFile(f) { const ext = (f.name || "").split(".").pop().toLowerCase(); const mime = f.type || ""; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime === "application/pdf" || ext === "pdf") return "document"; return "text"; } function extractCodeBlocks(markdown) {
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; } const regex = /```(\S*?)(?::(\S+?))?\s*?\n(.*?)```/gs;
const blocks = [];
let m;
while ((m = regex.exec(markdown)) !== null) {
const lang = (m[1] || "").split(":")[0];
const filename = m[2] || null;
const code = m[3]?.trim();
if (code && filename) {
blocks.push({ language: lang, file_path: filename, content: code });
}
}
return blocks;
}
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const perms = usePermissions(); const currentChat = state.chats.find((c) => c.id === chatId);
const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin"; const isStreamingGlobal = !!state.activeStreams[chatId];
const userPerms = state.user?.permissions || {};
// Filter models based on permissions
const MODELS = useMemo(() => {
const allowed = perms.allowed_models;
if (!allowed || allowed === "all") return ALL_MODELS;
const list = allowed.split(",").map(s => s.trim());
const filtered = ALL_MODELS.filter(m => list.includes(m.id));
return filtered.length > 0 ? filtered : ALL_MODELS;
}, [perms.allowed_models]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showTools, setShowTools] = useState(false); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [model, setModel] = useState(currentChat?.model || MODELS[0]?.id); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [maxTokens, setMaxTokens] = useState(Math.min(currentChat?.max_tokens || 4096, perms.max_tokens_cap || 4096)); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [reasoningBudget, setReasoningBudget] = useState(Math.min(currentChat?.reasoning_budget ?? 0, perms.max_reasoning_budget || 0));
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null); const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [selectedBranch, setSelectedBranch] = useState(currentChat?.linked_repo?.default_branch || "main");
const [webSearch, setWebSearch] = useState(false); const [webSearch, setWebSearch] = useState(false);
const [uiDesign, setUiDesign] = useState(false);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]); const [repos, setRepos] = useState([]);
const [branches, setBranches] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [exporting, setExporting] = useState("");
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null); // Commit state
const [showCommitModal, setShowCommitModal] = useState(false);
const [commitFiles, setCommitFiles] = useState([]);
const [commitBranch, setCommitBranch] = useState("");
const [commitMessage, setCommitMessage] = useState("");
const [committing, setCommitting] = useState(false);
const [commitResult, setCommitResult] = useState(null);
const scrollRef = useRef(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
const autoScroll = useRef(true);
const rafRef = useRef(null);
useEffect(() => {
setStreamData(streamManager.getStreamData(chatId));
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
setSelectedBranch(currentChat.linked_repo?.default_branch || "main");
}
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
}, []);
const maxTokensCap = perms.max_tokens_cap || 4096; useEffect(() => {
const maxReasoningCap = perms.max_reasoning_budget || 0; (async () => {
try {
const [msgs, kbData, repoData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
userPerms.can_use_gitlab ? listLinkedRepos(state.token) : Promise.resolve([]),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
setRepos(repoData);
} catch {}
})();
}, [chatId, state.token, dispatch]);
useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]); // Load branches when repo changes
const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []); useEffect(() => {
if (!selectedRepoId) { setBranches([]); return; }
(async () => {
try {
const br = await getRepoBranches(state.token, selectedRepoId);
setBranches(br);
const repo = repos.find(r => r.id === selectedRepoId);
if (repo) setSelectedBranch(repo.default_branch || "main");
} catch { setBranches([]); }
})();
}, [selectedRepoId, state.token]);
useEffect(() => { (async () => { try { const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), perms.can_use_knowledge_base ? listKnowledgeBases(state.token) : []]); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); setKbs(kbData); if (isSuperadmin || perms.can_use_gitlab) { try { setRepos(await gitlabListRepos(state.token)); } catch { } } } catch { } })(); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { if (currentChat) { const m = currentChat.model || MODELS[0]?.id; setModel(MODELS.find(x => x.id === m) ? m : MODELS[0]?.id); setMaxTokens(Math.min(currentChat.max_tokens || 4096, maxTokensCap)); setReasoningBudget(Math.min(currentChat.reasoning_budget ?? 0, maxReasoningCap)); setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedRepoId(currentChat.linked_repo_id || null); } }, [chatId]);
const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []); async function saveSettings() {
const saveSettings = useCallback(async () => { try { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); const repoObj = selectedRepoId ? repos.find(r => r.id === selectedRepoId) || null : null; dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo: repoObj } }); } catch { } }, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, repos, dispatch]); try {
await updateChat(state.token, chatId, {
model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "",
});
dispatch({ type: "UPDATE_CHAT", chat: {
id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId,
}});
} catch {}
}
function toggleSettings() {
if (showSettings) saveSettings();
setShowSettings(!showSettings);
}
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); setShowTools(false); } function handleFileSelect(e) {
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); } const files = Array.from(e.target.files || []);
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); } setPendingFiles((prev) => [...prev, ...files.map((f) => ({
file: f, type: classifyFile(f),
preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
}))]);
e.target.value = "";
}
function removePending(i) {
setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
}
const handleSend = useCallback(async () => { async function handleSend() {
const raw = input.trim(); if ((!raw && !pendingFiles.length) || streamData.streaming) return; const content = input.trim();
const text = raw || "Please analyze the attached file(s)."; if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const content = uiDesign ? UI_DESIGN_PREFIX + text : text; const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = []; let attIds = [], uploaded = [];
if (pendingFiles.length) { setUploading(true); try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch (e) { setUploading(false); alert(e.message); return; } setUploading(false); } if (pendingFiles.length) {
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } }); setUploading(true);
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true; try {
if (inputRef.current) inputRef.current.style.height = "auto"; const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
streamManager.startStream({ token: state.token, chatId, body: { content, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds, web_search: webSearch } }); uploaded = (res.attachments || []).filter((a) => !a.error);
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, webSearch, uiDesign, dispatch]); attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: {
id: `tmp-${Date.now()}`, role: "user", content: text,
created_at: new Date().toISOString(), attachments: uploaded,
}});
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: {
id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId,
}});
streamManager.startStream({
token: state.token, chatId,
body: {
content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, attachment_ids: attIds, web_search: webSearch,
},
});
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
const lastAssistantContent = messages.filter(m => m.role === "assistant").pop()?.content; function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => {
const f = i.getAsFile();
return { file: f, type: "image", preview: URL.createObjectURL(f) };
})]);
}
async function handleExport(type) { function handleDrop(e) {
if (!lastAssistantContent) return; e.preventDefault();
setExporting(type); setShowTools(false); const files = Array.from(e.dataTransfer?.files || []);
try { if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({
if (type === "pptx") await exportPptx(state.token, lastAssistantContent, currentChat?.title); file: f, type: classifyFile(f),
else if (type === "docx") await exportDocx(state.token, lastAssistantContent, currentChat?.title); preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null,
} catch (e) { alert(`Export failed: ${e.message}`); } }))]);
setExporting("");
} }
const streaming = streamData.streaming; // ── Commit Handlers ──
const linkedRepo = currentChat?.linked_repo; function openCommitSingle(fileData) {
setCommitFiles([fileData]);
const handleCommitFromChat = useCallback(async (filePath, code, action) => { setCommitBranch(selectedBranch || "main");
if (!linkedRepo) return; setCommitMessage(`Update ${fileData.file_path}`);
const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`); setCommitResult(null);
if (!msg) return; setShowCommitModal(true);
try { await gitlabCommitSingle(state.token, linkedRepo.id, { branch: linkedRepo.default_branch, file_path: filePath, content: code, commit_message: msg, action }); try { await refreshRepoContext(state.token, chatId); } catch { } } catch (e) { alert(`❌ ${e.message}`); throw e; } }
}, [linkedRepo, state.token, chatId]);
const canAttach = perms.can_use_attachments;
const canWebSearch = perms.can_use_web_search;
const canUiDesign = perms.can_use_ui_design;
const canExportPptx = perms.can_export_pptx;
const canExportDocx = perms.can_export_docx;
const canKB = perms.can_use_knowledge_base;
const canGitlab = perms.can_use_gitlab || isSuperadmin;
const hasAnyTool = canWebSearch || canUiDesign || canExportPptx || canExportDocx;
return ( function openCommitAll() {
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}> const allCode = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n");
{dragOver && canAttach && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>} const blocks = extractCodeBlocks(allCode);
if (!blocks.length) return;
// Deduplicate: keep last occurrence of each path
const map = new Map();
blocks.forEach((b) => map.set(b.file_path, b));
const deduped = Array.from(map.values());
setCommitFiles(deduped);
setCommitBranch(selectedBranch || "main");
setCommitMessage(`Update ${deduped.length} file${deduped.length > 1 ? "s" : ""} from Son of Anton`);
setCommitResult(null);
setShowCommitModal(true);
}
{linkedRepo && ( async function executeCommit() {
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs flex-wrap"> if (!commitFiles.length || committing) return;
<GitBranch size={12} className="text-orange-400" /><span className="text-orange-300 font-medium">{linkedRepo.name}</span><span className="text-orange-300/60">({linkedRepo.default_branch})</span> setCommitting(true);
</div> setCommitResult(null);
)} try {
const result = await commitFromChat(state.token, chatId, {
branch: commitBranch,
commit_message: commitMessage,
files: commitFiles.map((f) => ({ file_path: f.file_path, content: f.content })),
create_branch_if_missing: true,
});
setCommitResult({ ok: true, data: result });
} catch (err) {
setCommitResult({ ok: false, error: err.message });
} finally {
setCommitting(false);
}
}
{uiDesign && ( const streaming = streamData.streaming;
<div className="px-3 py-1.5 bg-blue-500/10 border-b border-blue-500/20 flex items-center gap-2 text-xs"> const hasLinkedRepo = !!selectedRepoId;
<Paintbrush size={12} className="text-blue-400" /><span className="text-blue-300 font-medium">UI Design Mode</span><span className="text-blue-300/60">— Generating previewable HTML designs</span> const hasCodeToCommit = hasLinkedRepo && messages.some((m) => m.role === "assistant" && m.content?.includes("```"));
<button onClick={() => setUiDesign(false)} className="ml-auto text-blue-400/60 hover:text-blue-300 transition"><X size={12} /></button> const selectedRepo = repos.find((r) => r.id === selectedRepoId);
</div>
)}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3"> return (
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />)} <div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{streaming && (streamData.thinking || streamData.text) && <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />} {/* Messages */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble
key={m.id} message={m} token={state.token}
onCommitFile={hasLinkedRepo ? openCommitSingle : undefined}
/>
))}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }}
isStreaming isThinking={streamData.isThinking} token={state.token}
onCommitFile={hasLinkedRepo ? openCommitSingle : undefined}
/>
)}
{streaming && !streamData.text && !streamData.thinking && ( {streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in"> <div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div> <div className="flex gap-1">
<span className="text-anton-muted text-sm">{uiDesign ? "Designing UI…" : webSearch ? "Searching web & thinking…" : linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span> <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div> </div>
)} )}
</div> </div>
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom"> {/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Settings panel */}
{showSettings && ( {showSettings && (
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto"> <div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div> <div className="flex items-center justify-between">
<div><label className="text-xs text-anton-muted mb-1 block">Model</label><select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}</select></div> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Chat Settings</h3>
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}{maxTokensCap < 65536 && <span className="text-anton-muted"> / {maxTokensCap.toLocaleString()}</span>}</span></div><input type="range" min={256} max={maxTokensCap} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div> <button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
{maxReasoningCap > 0 && ( </div>
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}{maxReasoningCap < 32000 && <span className="text-anton-muted"> / {maxReasoningCap.toLocaleString()}</span>}</span></div><input type="range" min={0} max={maxReasoningCap} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div> <div>
)} <label className="text-xs text-anton-muted mb-1 block">Model</label>
{canKB && ( <select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div> {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" />
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{/* GitLab Repo Selection */}
{userPerms.can_use_gitlab && repos.length > 0 && (
<>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Linked Repository</label>
<select value={selectedRepoId || ""} onChange={(e) => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{repos.map((r) => <option key={r.id} value={r.id}>{r.name} ({r.path_with_namespace})</option>)}
</select>
</div>
{selectedRepoId && branches.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} /> Branch</label>
<select value={selectedBranch} onChange={(e) => setSelectedBranch(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}{b.default ? " (default)" : ""}</option>)}
</select>
</div>
)}
</>
)} )}
{canGitlab && repos.length > 0 && ( {/* Web Search Toggle */}
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label><select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"><option value="">None</option>{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name}</option>)}</select></div> {userPerms.can_use_web_search && (
<div className="flex items-center justify-between">
<label className="text-xs text-anton-muted flex items-center gap-1"><Globe size={12} className="text-blue-400" /> Web Search</label>
<button onClick={() => setWebSearch(!webSearch)}
className={`w-10 h-5 rounded-full transition ${webSearch ? "bg-blue-500" : "bg-anton-border"} relative`}>
<div className={`w-4 h-4 rounded-full bg-white absolute top-0.5 transition-all ${webSearch ? "left-5" : "left-0.5"}`} />
</button>
</div>
)} )}
</div> </div>
)} )}
{/* Pending files */}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in"> <div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => (
const Icon = TYPE_ICONS[pf.type] || FileText; return ( <div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}> {pf.type === "image" && pf.preview ? (
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" loading="lazy" /> : (<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1"><Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /><span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span></div>)} <img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
<button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button> ) : (
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div> <div className="w-16 h-16 flex flex-col items-center justify-center px-1">
</div> <FileText size={20} className="text-anton-muted mb-1" />
); <span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
})} </div>
)}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div>
))}
</div> </div>
)} )}
<div className="flex items-end gap-1.5"> {/* Input row */}
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button> <div className="flex items-end gap-2">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
{hasAnyTool && ( <button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<div className="relative"> <input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<button onClick={() => { setShowTools(!showTools); setShowSettings(false); }} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showTools ? "bg-blue-500/20 text-blue-400" : (webSearch || uiDesign) ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Tools"><Wand2 size={18} /></button> <div className="flex-1 relative">
{showTools && ( <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
<div className="absolute bottom-full left-0 mb-2 w-56 bg-anton-card border border-anton-border rounded-xl shadow-2xl p-2 space-y-1 animate-fade-in z-30"> placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
<p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p> rows={1} style={{ maxHeight: "200px" }}
{canUiDesign && ( className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
<button onClick={() => setUiDesign(!uiDesign)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${uiDesign ? "bg-blue-500/15 text-blue-400" : "text-white hover:bg-anton-bg"}`}> onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
<span className="flex items-center gap-2"><Paintbrush size={15} /> UI Design</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${uiDesign ? "bg-blue-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${uiDesign ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{canWebSearch && (
<button onClick={() => setWebSearch(!webSearch)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${webSearch ? "bg-green-500/15 text-green-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Globe size={15} /> Web Search</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${webSearch ? "bg-green-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${webSearch ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{(canExportPptx || canExportDocx) && <hr className="border-anton-border" />}
{canExportPptx && (
<button onClick={() => handleExport("pptx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "pptx" ? <Loader2 size={15} className="animate-spin" /> : <Presentation size={15} className="text-orange-400" />} Download PPTX
</button>
)}
{canExportDocx && (
<button onClick={() => handleExport("docx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "docx" ? <Loader2 size={15} className="animate-spin" /> : <FileOutput size={15} className="text-blue-400" />} Download DOCX
</button>
)}
</div>
)}
</div>
)}
{canAttach && (
<>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
</>
)}
<div className="flex-1 min-w-0">
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={uiDesign ? "Describe a UI design…" : webSearch ? "Search the web & ask…" : pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button> <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
) : ( ) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30"> <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"> {/* Status bar */}
<span>{MODELS.find(m => m.id === model)?.label}</span> <div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap">
<span></span><span>{maxTokens.toLocaleString()} tok</span> <span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{uiDesign && <><span></span><span className="text-blue-400">🎨 UI Design</span></>}
{webSearch && <><span></span><span className="text-green-400">🌐 Web</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>} {webSearch && <><span></span><span className="text-blue-400">🌐 Web</span></>}
{selectedRepo && <><span></span><span className="text-orange-400">🔗 {selectedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
{messages.some(m => m.role === "assistant") && <button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>} <div className="ml-auto flex items-center gap-2">
{hasCodeToCommit && (
<button onClick={openCommitAll} className="flex items-center gap-1 hover:text-green-400 transition text-green-500/70">
<GitCommitHorizontal size={12} /> Commit All Code
</button>
)}
{messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
className="hover:text-anton-accent transition">⬇ Download code</button>
)}
</div>
</div> </div>
</div> </div>
{/* Commit Modal */}
{showCommitModal && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => !committing && setShowCommitModal(false)}>
<div className="bg-anton-card border border-anton-border rounded-2xl p-6 w-full max-w-lg shadow-2xl animate-fade-in" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-white flex items-center gap-2 mb-4">
<GitCommitHorizontal size={20} className="text-green-400" />
Commit to {selectedRepo?.name || "Repository"}
</h3>
{commitResult ? (
<div className={`p-4 rounded-xl mb-4 ${commitResult.ok ? "bg-green-500/10 border border-green-500/30" : "bg-red-500/10 border border-red-500/30"}`}>
<div className="flex items-center gap-2 mb-2">
{commitResult.ok ? <Check size={16} className="text-green-400" /> : <AlertCircle size={16} className="text-red-400" />}
<span className={`font-semibold ${commitResult.ok ? "text-green-400" : "text-red-400"}`}>
{commitResult.ok ? "Commit Successful!" : "Commit Failed"}
</span>
</div>
{commitResult.ok && (
<div className="text-xs text-anton-muted space-y-1">
<div>Files committed: {commitResult.data.files_committed}</div>
<div>Branch: {commitResult.data.branch}</div>
{commitResult.data.branch_created && <div className="text-orange-400">New branch created</div>}
{commitResult.data.commit?.web_url && (
<a href={commitResult.data.commit.web_url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">View commit →</a>
)}
</div>
)}
{!commitResult.ok && <div className="text-xs text-red-300">{commitResult.error}</div>}
<button onClick={() => setShowCommitModal(false)} className="mt-3 px-4 py-1.5 bg-anton-bg rounded-lg text-sm text-white hover:bg-anton-border transition">Close</button>
</div>
) : (
<>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs text-anton-muted block mb-1">Branch</label>
<div className="flex gap-2">
<select value={commitBranch} onChange={(e) => setCommitBranch(e.target.value)}
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}</option>)}
</select>
<input type="text" value={commitBranch} onChange={(e) => setCommitBranch(e.target.value)}
placeholder="or type new branch name"
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
</div>
<p className="text-[10px] text-anton-muted mt-1">Select existing branch or type a new name — it'll be created automatically.</p>
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Commit Message</label>
<input type="text" value={commitMessage} onChange={(e) => setCommitMessage(e.target.value)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Files ({commitFiles.length})</label>
<div className="max-h-40 overflow-y-auto space-y-1">
{commitFiles.map((f, i) => (
<div key={i} className="flex items-center justify-between bg-anton-bg rounded-lg px-3 py-1.5 text-xs">
<span className="text-white font-mono truncate">{f.file_path}</span>
<button onClick={() => setCommitFiles((prev) => prev.filter((_, j) => j !== i))} className="text-anton-muted hover:text-red-400 shrink-0 ml-2"><X size={12} /></button>
</div>
))}
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowCommitModal(false)} className="px-4 py-2 text-sm text-anton-muted hover:text-white transition">Cancel</button>
<button onClick={executeCommit} disabled={!commitFiles.length || !commitBranch || !commitMessage || committing}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-500 transition disabled:opacity-40 flex items-center gap-2">
{committing ? <Loader2 size={14} className="animate-spin" /> : <GitCommitHorizontal size={14} />}
{committing ? "Committing..." : `Commit ${commitFiles.length} file${commitFiles.length !== 1 ? "s" : ""}`}
</button>
</div>
</>
)}
</div>
</div>
)}
</div> </div>
); );
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment