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:
Evandro Fonseca Junior committed 2026-04-02 09:06:06 -03:00
1 parent 3364dba5aa
commit cb722e79a6
11 files changed
+1695 -254

No files matched your search

@@ -0,0 +1,102 @@
import { useState, useRef, useEffect } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { getDisplayName, isInChatSelector } from "../lib/modelAlias";
interface ModelSelectorPillProps {
models: ModelInfo[];
localModels: RuntimeModelFile[];
selectedModel: string;
onModelChange: (id: string) => void;
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: ServerStatus;
}
export default function ModelSelectorPill({
models,
localModels,
selectedModel,
onModelChange,
onModelSwap,
isSwapping,
status,
}: ModelSelectorPillProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!dropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownOpen]);
const handleSelectModel = (modelId: string) => {
setDropdownOpen(false);
onModelChange(modelId);
if (onModelSwap) {
onModelSwap(modelId);
}
};
// Unified model list: merge API models + local scanned files
const allAvailableModels = [
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
];
// Deduplicate by id
const seen = new Set<string>();
const dedupedModels = allAvailableModels.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
// Chat selector: only Cooper + Iron (Steel is settings-only)
const chatSelectorModels = dedupedModels.filter((m) => isInChatSelector(m.id));
const selectedLabel = getDisplayName(selectedModel) || "No model";
return (
<div className="model-selector-pill-wrapper" ref={dropdownRef}>
<button
type="button"
className="model-selector-pill"
onClick={() => setDropdownOpen(!dropdownOpen)}
disabled={chatSelectorModels.length === 0 || isSwapping}
title={chatSelectorModels.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
aria-expanded={dropdownOpen}
>
<span className={`model-status-dot status-${status}`} aria-hidden="true" />
{isSwapping ? (
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
) : (
<span className="model-selector-label">{selectedLabel}</span>
)}
<ChevronDown size={12} strokeWidth={2} className="model-selector-chevron" />
</button>
{dropdownOpen && (
<ul className="model-selector-menu" role="listbox">
{chatSelectorModels.map((m) => (
<li
key={m.id}
role="option"
aria-selected={m.id === selectedModel}
className={`model-selector-item${m.id === selectedModel ? " model-selector-item--selected" : ""}`}
onClick={() => handleSelectModel(m.id)}
>
{m.label}
</li>
))}
</ul>
)}
</div>
);
}