feat(ui): complete Plan 06-01 - fix model scan + move model selector to input
- Fix Rust model scan to only list top-level models (not subdirectories) - Add scan_model_dir_top_level() function in model_files.rs - Create ModelSelectorPill component for chat input area - Remove model-section from Sidebar, move to ChatView - Update ChatView props to receive model-related props - Update tests for new ChatView props
This commit is contained in:
1 parent
3364dba5aa
commit
cb722e79a6
11 files changed
+1695
-254
No files matched your search
@@ -1,27 +1,46 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { DB } from "../lib/db";
|
||||
import type { ChatTokenTotals, Message, Usage } from "../lib/types";
|
||||
import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types";
|
||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
|
||||
import { renderMarkdown, ensureHljs } from "../lib/markdown";
|
||||
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText } from "@phosphor-icons/react";
|
||||
import ModelSelectorPill from "./ModelSelectorPill";
|
||||
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
|
||||
import { getDisplayName } from "../lib/modelAlias";
|
||||
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react";
|
||||
import "./ChatView.css";
|
||||
|
||||
const MAX_MEMORY = 20;
|
||||
|
||||
/** Model used for background memory extraction (small/fast). */
|
||||
const MEMORY_EXTRACTOR_MODEL = "smollm2";
|
||||
|
||||
interface Props {
|
||||
db: DB;
|
||||
chatId: number | null;
|
||||
server: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
models: ModelInfo[];
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (id: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
status: ServerStatus;
|
||||
onChatCreated: (id: number) => void;
|
||||
onChatUpdated: () => void;
|
||||
onOpenSidebar?: () => void;
|
||||
onDegradationDetected?: () => void;
|
||||
}
|
||||
|
||||
export default function ChatView({ db, chatId, server, apiKey, model, onChatCreated, onChatUpdated, onOpenSidebar }: Props) {
|
||||
export default function ChatView({
|
||||
db, chatId, server, apiKey, model,
|
||||
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status,
|
||||
onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
@@ -44,7 +63,11 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
const pendingRequestChatIdRef = useRef<number | null>(null);
|
||||
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
|
||||
const messageQueueRef = useRef<string[]>([]);
|
||||
const sendRef = useRef<(text: string, fromChatId?: number) => Promise<void>>(async () => {});
|
||||
const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise<void>>(async () => {});
|
||||
// Tracks accumulated stream content so abort can persist it
|
||||
const streamContentAccRef = useRef<string>("");
|
||||
// TPS degradation detection: timer fires if TPS stays < 30 for 5s
|
||||
const degradationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Single helper to keep ref and state in sync — use at every queue mutation site
|
||||
const setQueue = (next: string[]) => {
|
||||
@@ -70,8 +93,42 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
)
|
||||
: 0;
|
||||
|
||||
// ── Memory extractor (background, fire-and-forget) ────────────────────────
|
||||
const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({
|
||||
db,
|
||||
server,
|
||||
apiKey,
|
||||
memoryModel: MEMORY_EXTRACTOR_MODEL,
|
||||
enabled: useMemory,
|
||||
});
|
||||
|
||||
useEffect(() => { ensureHljs(); }, []);
|
||||
|
||||
// TPS degradation detection: if TPS < 30 for 5 continuous seconds, notify
|
||||
useEffect(() => {
|
||||
if (tps === null) return;
|
||||
if (tps < 30) {
|
||||
if (!degradationTimerRef.current) {
|
||||
degradationTimerRef.current = setTimeout(() => {
|
||||
onDegradationDetected?.();
|
||||
degradationTimerRef.current = null;
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
// TPS recovered — cancel pending timer
|
||||
if (degradationTimerRef.current) {
|
||||
clearTimeout(degradationTimerRef.current);
|
||||
degradationTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (degradationTimerRef.current) {
|
||||
clearTimeout(degradationTimerRef.current);
|
||||
degradationTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tps, onDegradationDetected]);
|
||||
|
||||
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
|
||||
if (targetChatId == null) { setMessages([]); return; }
|
||||
const rows = db.query<Message>(
|
||||
@@ -146,13 +203,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
|
||||
const buildApiMessages = useCallback((cid: number) => {
|
||||
const limit = useMemory ? MAX_MEMORY : 1;
|
||||
return db.query<{ role: string; content: string }>(
|
||||
const history = db.query<{ role: string; content: string }>(
|
||||
"SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?",
|
||||
[cid, limit],
|
||||
).reverse();
|
||||
}, [db, useMemory]);
|
||||
|
||||
const send = useCallback(async (text: string, fromChatId?: number) => {
|
||||
// Prepend system prompt with user memories when memory mode is on
|
||||
const systemPrompt = useMemory ? buildMemorySystemPrompt() : "";
|
||||
if (systemPrompt) {
|
||||
return [{ role: "system", content: systemPrompt }, ...history];
|
||||
}
|
||||
return history;
|
||||
}, [db, useMemory, buildMemorySystemPrompt]);
|
||||
|
||||
const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => {
|
||||
if (!text.trim() || generating) return;
|
||||
if (getComposerNotice(model, server)) return;
|
||||
|
||||
@@ -162,14 +226,22 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
setStreamTokenCount(0);
|
||||
streamContentAccRef.current = "";
|
||||
|
||||
const cid = fromChatId ?? getOrCreateChat(text);
|
||||
activeChatIdRef.current = cid;
|
||||
pendingRequestChatIdRef.current = cid;
|
||||
|
||||
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
|
||||
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
|
||||
if (count?.c === 1) ensureChatTitle(cid, text);
|
||||
// When replaceAssistantId is set we are resending — the user message already
|
||||
// exists in the DB; we only delete the old assistant reply and replace it.
|
||||
if (replaceAssistantId == null) {
|
||||
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
|
||||
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
|
||||
if (count?.c === 1) ensureChatTitle(cid, text);
|
||||
} else {
|
||||
// Delete the old assistant message that we're replacing
|
||||
db.run("DELETE FROM messages WHERE id = ?", [replaceAssistantId]);
|
||||
}
|
||||
|
||||
loadMessagesRef.current(cid);
|
||||
onChatUpdated();
|
||||
@@ -226,6 +298,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
tokenCount++;
|
||||
setStreamTokenCount(tokenCount);
|
||||
fullContent += delta;
|
||||
streamContentAccRef.current = fullContent;
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
setStreamContent(parsed.content);
|
||||
setStreamReasoning(parsed.reasoning);
|
||||
@@ -239,6 +312,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
} else {
|
||||
const data = await res.json();
|
||||
fullContent = data.choices?.[0]?.message?.content || "";
|
||||
streamContentAccRef.current = fullContent;
|
||||
usage = data.usage;
|
||||
const parsed = parseAssistantResponse(fullContent);
|
||||
setStreamContent(parsed.content);
|
||||
@@ -269,8 +343,24 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
|
||||
onChatUpdated();
|
||||
|
||||
// Fire background memory extraction (non-blocking)
|
||||
setTimeout(() => { extractMemories(cid).catch(() => undefined); }, 500);
|
||||
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// ── Save partial response on user-initiated stop ──────────────────
|
||||
const partial = streamContentAccRef.current;
|
||||
if (partial.trim()) {
|
||||
const parsed = parseAssistantResponse(partial);
|
||||
const partialContent = parsed.content.trim() || partial.trim();
|
||||
db.run(
|
||||
`INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`,
|
||||
[cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null],
|
||||
);
|
||||
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
|
||||
onChatUpdated();
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
|
||||
cid,
|
||||
`⚠️ Error: ${err.message}`,
|
||||
@@ -284,6 +374,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
setStreamThinkingOpen(false);
|
||||
setShowLiveReasoning(false);
|
||||
setStreamTokenCount(0);
|
||||
streamContentAccRef.current = "";
|
||||
|
||||
if (activeChatIdRef.current === cid) {
|
||||
loadMessagesRef.current(cid);
|
||||
@@ -298,7 +389,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
setTimeout(() => sendRef.current(nextInQueue), 100);
|
||||
}
|
||||
}
|
||||
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated]);
|
||||
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]);
|
||||
|
||||
// Keep sendRef always pointing to latest send closure
|
||||
sendRef.current = send;
|
||||
@@ -346,6 +437,31 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resend: regenerate the assistant reply for an existing assistant message.
|
||||
* Finds the preceding user message and calls send() with replaceAssistantId
|
||||
* so the old assistant row is replaced — no duplicate user message is inserted.
|
||||
*/
|
||||
const handleResend = useCallback((assistantMsg: Message) => {
|
||||
if (generating) return;
|
||||
// Find the user message immediately before this assistant message
|
||||
const userMsg = db.get<Message>(
|
||||
`SELECT * FROM messages
|
||||
WHERE chat_id = ? AND id < ? AND role = 'user'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[assistantMsg.chat_id, assistantMsg.id],
|
||||
);
|
||||
if (!userMsg) return;
|
||||
|
||||
// Delete any messages after the user message up to and including this assistant msg
|
||||
db.run(
|
||||
"DELETE FROM messages WHERE chat_id = ? AND id >= ? AND id <= ?",
|
||||
[assistantMsg.chat_id, userMsg.id + 1, assistantMsg.id],
|
||||
);
|
||||
loadMessages();
|
||||
send(userMsg.content, assistantMsg.chat_id, assistantMsg.id);
|
||||
}, [generating, db, send, loadMessages]);
|
||||
|
||||
const handleEditAssistant = (id: number, newContent: string) => {
|
||||
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]);
|
||||
loadMessages();
|
||||
@@ -353,10 +469,23 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
|
||||
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
|
||||
|
||||
const modelShort = model.length > 30 ? model.slice(0, 28) + "…" : model;
|
||||
const isInitialScreen = chatId === null && messages.length === 0;
|
||||
const modelShort = getDisplayName(model);
|
||||
const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation";
|
||||
const workspaceStateCopy = composerNotice
|
||||
? composerNotice.title
|
||||
: server
|
||||
? "Runtime ready"
|
||||
: "Runtime offline";
|
||||
const heroCards = [
|
||||
{
|
||||
title: "Personalize ForgeLM",
|
||||
copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
|
||||
{/* ── Top bar ── */}
|
||||
<div className="chat-topbar">
|
||||
<div className="chat-topbar-left">
|
||||
@@ -366,69 +495,81 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
</button>
|
||||
)}
|
||||
{model && (
|
||||
<div className="topbar-model-badge">
|
||||
<div className={`topbar-status-dot${server ? " connected" : ""}`} />
|
||||
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
|
||||
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
|
||||
<span className="topbar-model-name">{modelShort}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-topbar-center">
|
||||
{chatId !== null && chatTotals.total_tokens > 0 && (
|
||||
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
|
||||
<div className="chat-token-summary">
|
||||
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
|
||||
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
|
||||
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-topbar-context" aria-label="Workspace status">
|
||||
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
|
||||
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-topbar-right">
|
||||
{generating && streamTokenCount > 0 && (
|
||||
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
|
||||
{server ? "Runtime ready" : "Runtime offline"}
|
||||
</span>
|
||||
{!isInitialScreen && generating && streamTokenCount > 0 && (
|
||||
<div className="live-token-counter">
|
||||
<span className="live-token-pulse" />
|
||||
<span>{streamTokenCount.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
)}
|
||||
{tps !== null && !generating && (
|
||||
{!isInitialScreen && tps !== null && !generating && (
|
||||
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
|
||||
onClick={() => setShowMemoryModal(!showMemoryModal)}
|
||||
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
|
||||
aria-label="View memory context"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
||||
</svg>
|
||||
{memoryMessageCount > 0 && (
|
||||
<span className="memory-count-badge">{memoryMessageCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{!isInitialScreen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
|
||||
onClick={() => setShowMemoryModal(!showMemoryModal)}
|
||||
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
|
||||
aria-label="View memory context"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
||||
</svg>
|
||||
{memoryMessageCount > 0 && (
|
||||
<span className="memory-count-badge">{memoryMessageCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="topbar-toggles">
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pill${useStream ? " on" : " off"}`}
|
||||
onClick={() => setUseStream(!useStream)}
|
||||
title={useStream ? "Streaming enabled" : "Streaming disabled"}
|
||||
>
|
||||
Stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pill${useMemory ? " on" : " off"}`}
|
||||
onClick={() => setUseMemory(!useMemory)}
|
||||
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
|
||||
>
|
||||
Memory
|
||||
</button>
|
||||
</div>
|
||||
<div className="topbar-toggles">
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pill${useStream ? " on" : " off"}`}
|
||||
onClick={() => setUseStream(!useStream)}
|
||||
title={useStream ? "Streaming enabled" : "Streaming disabled"}
|
||||
>
|
||||
Stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pill${useMemory ? " on" : " off"}`}
|
||||
onClick={() => setUseMemory(!useMemory)}
|
||||
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
|
||||
>
|
||||
Memory
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -472,38 +613,118 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
<div className="chat-layout-root">
|
||||
{/* Scrollable message area */}
|
||||
<div className="chat-scroll-area">
|
||||
<div className="messages-container">
|
||||
<div className={`messages-container${isInitialScreen ? " messages-container--hero" : ""}`}>
|
||||
{chatId === null && messages.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-panel">
|
||||
<div className="empty-icon">⚡</div>
|
||||
<span className="empty-kicker">Local-first AI workspace</span>
|
||||
<h2>Start with a prompt that moves the project forward.</h2>
|
||||
<p>
|
||||
ForgeLM keeps your chats, code blocks, and runtime controls in one place so you can inspect,
|
||||
iterate, and debug without losing context.
|
||||
</p>
|
||||
<div className="empty-state-actions">
|
||||
<div className="empty-state empty-state--hero">
|
||||
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
|
||||
<div className="empty-state-header empty-state-header--hero">
|
||||
<span className="empty-kicker">ForgeLM workspace</span>
|
||||
<h1 id="workspace-welcome">What can I do for you?</h1>
|
||||
</div>
|
||||
|
||||
<div className="hero-composer-block">
|
||||
<div className="input-box input-box--hero">
|
||||
<ModelSelectorPill
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
onModelSwap={onModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
/>
|
||||
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
|
||||
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
|
||||
<Image size={20} weight="duotone" />
|
||||
</button>
|
||||
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
|
||||
<Microphone size={20} weight="duotone" />
|
||||
</button>
|
||||
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
|
||||
<Paperclip size={20} weight="duotone" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="input-box-main">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||
}}
|
||||
placeholder={
|
||||
composerNotice
|
||||
? `${composerNotice.title}. ${composerNotice.description}`
|
||||
: generating
|
||||
? "Type to queue next message…"
|
||||
: "Assign a task or ask anything"
|
||||
}
|
||||
rows={1}
|
||||
disabled={Boolean(composerNotice)}
|
||||
/>
|
||||
|
||||
{generating ? (
|
||||
<button className="stop-btn" onClick={handleStop} type="button">
|
||||
■ Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || Boolean(composerNotice)}
|
||||
type="button"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<PaperPlaneRight size={18} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="composer-footer-row">
|
||||
<span className="composer-footer-hint">
|
||||
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
|
||||
</span>
|
||||
<div className="composer-footer-icons" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="starter-chip-rail" aria-label="Starter prompts">
|
||||
{starterPrompts.map((prompt) => (
|
||||
<button key={prompt} type="button" className="starter-chip" onClick={() => handleStarterPrompt(prompt)}>
|
||||
<button key={prompt} type="button" className="starter-chip starter-chip--hero" onClick={() => handleStarterPrompt(prompt)}>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{composerNotice && (
|
||||
<div className={`composer-notice ${composerNotice.tone}`}>
|
||||
<div>
|
||||
<strong>{composerNotice.title}</strong>
|
||||
<p>{composerNotice.description}</p>
|
||||
</div>
|
||||
{onOpenSidebar && (
|
||||
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
|
||||
{composerNotice.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="hero-card-carousel" aria-label="Workspace shortcuts">
|
||||
{heroCards.map((card) => (
|
||||
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
|
||||
<div>
|
||||
<span className="hero-card-title">{card.title}</span>
|
||||
<span className="hero-card-copy">{card.copy}</span>
|
||||
</div>
|
||||
<div className="hero-card-preview" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="hero-card-pager" aria-hidden="true">
|
||||
<span className="active" />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -514,6 +735,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
onDelete={handleDeleteMessage}
|
||||
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
|
||||
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
|
||||
onResend={msg.role === "assistant" ? handleResend : undefined}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
))}
|
||||
@@ -552,8 +774,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
)}
|
||||
|
||||
{!streamContent && streamThinkingOpen && (
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
<div className="thinking-manus">
|
||||
<span className="thinking-dot" aria-hidden="true" />
|
||||
<span className="thinking-label">Thinking</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -562,8 +785,9 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
|
||||
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
||||
<div className="msg-wrapper assistant">
|
||||
<div className="thinking">
|
||||
<span /><span /><span />
|
||||
<div className="thinking-manus">
|
||||
<span className="thinking-dot" aria-hidden="true" />
|
||||
<span className="thinking-label">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -574,7 +798,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
</div>
|
||||
|
||||
{/* ── Message Queue indicator ── */}
|
||||
{messageQueue.length > 0 && (
|
||||
{!isInitialScreen && messageQueue.length > 0 && (
|
||||
<div className="queue-indicator">
|
||||
<span className="queue-indicator-pulse" />
|
||||
<span className="queue-indicator-text">
|
||||
@@ -591,6 +815,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
)}
|
||||
|
||||
{/* ── Fixed Input ── */}
|
||||
{!isInitialScreen && (
|
||||
<div className="input-sticky-wrapper">
|
||||
<div className="input-gradient-mask" />
|
||||
<div className="input-content-constrained">
|
||||
@@ -625,6 +850,15 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
|
||||
{/* The typing pill */}
|
||||
<div className="input-box">
|
||||
<ModelSelectorPill
|
||||
models={models}
|
||||
localModels={localModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
onModelSwap={onModelSwap}
|
||||
isSwapping={isSwapping}
|
||||
status={status}
|
||||
/>
|
||||
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
|
||||
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
|
||||
<Image size={20} weight="duotone" />
|
||||
@@ -667,7 +901,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
type="button"
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send <ArrowUp size={18} weight="duotone" />
|
||||
<PaperPlaneRight size={18} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -710,6 +944,7 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user