Detailed changes
@@ -382,6 +382,7 @@ function App() {
onToggleDrawerCollapse={toggleDrawerCollapsed}
openDiffViewerTrigger={diffViewerTrigger}
modelsRefreshTrigger={modelsRefreshTrigger}
+ onOpenModelsModal={() => setModelsModalOpen(true)}
/>
</div>
@@ -28,6 +28,7 @@ import OutputIframeTool from "./OutputIframeTool";
import DirectoryPickerModal from "./DirectoryPickerModal";
import { useVersionChecker } from "./VersionChecker";
import TerminalWidget from "./TerminalWidget";
+import ModelPicker from "./ModelPicker";
// Ephemeral terminal instance (not persisted to database)
interface EphemeralTerminal {
@@ -462,6 +463,7 @@ interface ChatInterfaceProps {
onToggleDrawerCollapse?: () => void;
openDiffViewerTrigger?: number; // increment to trigger opening diff viewer
modelsRefreshTrigger?: number; // increment to trigger models list refresh
+ onOpenModelsModal?: () => void;
}
function ChatInterface({
@@ -479,6 +481,7 @@ function ChatInterface({
onToggleDrawerCollapse,
openDiffViewerTrigger,
modelsRefreshTrigger,
+ onOpenModelsModal,
}: ChatInterfaceProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
@@ -573,7 +576,6 @@ function ChatInterface({
}, [modelsRefreshTrigger, conversationId]);
const [cwdError, setCwdError] = useState<string | null>(null);
- const [editingModel, setEditingModel] = useState(false);
const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
// Settings modal removed - configuration moved to status bar for empty conversations
const [showOverflowMenu, setShowOverflowMenu] = useState(false);
@@ -1613,31 +1615,13 @@ function ChatInterface({
title="AI model to use for this conversation"
>
<span className="status-field-label">Model:</span>
- {editingModel ? (
- <select
- id="model-select-status"
- value={selectedModel}
- onChange={(e) => setSelectedModel(e.target.value)}
- onBlur={() => setEditingModel(false)}
- disabled={sending}
- className="status-select"
- autoFocus
- >
- {models.map((model) => (
- <option key={model.id} value={model.id} disabled={!model.ready}>
- {model.display_name || model.id} {!model.ready ? "(not ready)" : ""}
- </option>
- ))}
- </select>
- ) : (
- <button
- className="status-chip"
- onClick={() => setEditingModel(true)}
- disabled={sending}
- >
- {models.find((m) => m.id === selectedModel)?.display_name || selectedModel}
- </button>
- )}
+ <ModelPicker
+ models={models}
+ selectedModel={selectedModel}
+ onSelectModel={setSelectedModel}
+ onManageModels={() => onOpenModelsModal?.()}
+ disabled={sending}
+ />
</div>
{/* CWD indicator - far right */}
@@ -0,0 +1,153 @@
+import React, { useState, useRef, useEffect } from "react";
+import { Model } from "../types";
+
+interface ModelPickerProps {
+ models: Model[];
+ selectedModel: string;
+ onSelectModel: (modelId: string) => void;
+ onManageModels: () => void;
+ disabled?: boolean;
+}
+
+function ModelPicker({
+ models,
+ selectedModel,
+ onSelectModel,
+ onManageModels,
+ disabled = false,
+}: ModelPickerProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [openUpward, setOpenUpward] = useState(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const dropdownRef = useRef<HTMLDivElement>(null);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen]);
+
+ // Close on escape
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }
+ }, [isOpen]);
+
+ // Determine if dropdown should open upward
+ useEffect(() => {
+ if (isOpen && containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ const spaceBelow = window.innerHeight - rect.bottom;
+ const dropdownHeight = 320; // approximate max height
+ setOpenUpward(spaceBelow < dropdownHeight && rect.top > spaceBelow);
+ }
+ }, [isOpen]);
+
+ const selectedModelObj = models.find((m) => m.id === selectedModel);
+ const displayName = selectedModelObj?.display_name || selectedModel;
+
+ const handleSelect = (modelId: string) => {
+ onSelectModel(modelId);
+ setIsOpen(false);
+ };
+
+ const handleManageModels = () => {
+ setIsOpen(false);
+ onManageModels();
+ };
+
+ return (
+ <div className="model-picker" ref={containerRef}>
+ <button
+ className="model-picker-trigger"
+ onClick={() => !disabled && setIsOpen(!isOpen)}
+ disabled={disabled}
+ type="button"
+ >
+ <span className="model-picker-value">{displayName}</span>
+ <svg
+ className={`model-picker-chevron ${isOpen ? "open" : ""}`}
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ >
+ <path d="M6 9l6 6 6-6" />
+ </svg>
+ </button>
+
+ {isOpen && (
+ <div
+ className={`model-picker-dropdown ${openUpward ? "open-upward" : ""}`}
+ ref={dropdownRef}
+ >
+ <div className="model-picker-options">
+ {models.map((model) => (
+ <button
+ key={model.id}
+ className={`model-picker-option ${model.id === selectedModel ? "selected" : ""} ${!model.ready ? "disabled" : ""}`}
+ onClick={() => model.ready && handleSelect(model.id)}
+ disabled={!model.ready}
+ type="button"
+ >
+ <span className="model-picker-option-name">{model.display_name || model.id}</span>
+ {!model.ready && <span className="model-picker-option-badge">not ready</span>}
+ {model.id === selectedModel && (
+ <svg
+ className="model-picker-option-check"
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ >
+ <path d="M20 6L9 17l-5-5" />
+ </svg>
+ )}
+ </button>
+ ))}
+ </div>
+ <div className="model-picker-divider" />
+ <button
+ className="model-picker-option model-picker-manage"
+ onClick={handleManageModels}
+ type="button"
+ >
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ >
+ <path d="M12 4v16m-8-8h16" />
+ </svg>
+ <span>Add / Remove Models...</span>
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default ModelPicker;
@@ -2750,6 +2750,156 @@ svg {
cursor: not-allowed;
}
+/* Model Picker Custom Dropdown */
+.model-picker {
+ position: relative;
+ display: inline-block;
+ width: 100%;
+}
+
+.model-picker-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: all 0.2s;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ min-height: 1.75rem;
+}
+
+.model-picker-trigger:hover:not(:disabled) {
+ background: var(--bg-secondary);
+ border-color: var(--blue-text);
+}
+
+.model-picker-trigger:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.model-picker-value {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.model-picker-chevron {
+ flex-shrink: 0;
+ transition: transform 0.2s;
+ opacity: 0.6;
+}
+
+.model-picker-chevron.open {
+ transform: rotate(180deg);
+}
+
+.model-picker-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 2px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ max-height: 300px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.model-picker-dropdown.open-upward {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: 2px;
+}
+
+.model-picker-options {
+ overflow-y: auto;
+ max-height: 220px;
+}
+
+.model-picker-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.15s;
+}
+
+.model-picker-option:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+}
+
+.model-picker-option.selected {
+ background: var(--bg-tertiary);
+}
+
+.model-picker-option.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.model-picker-option-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.model-picker-option-badge {
+ font-size: 0.65rem;
+ color: var(--text-secondary);
+ background: var(--bg-base);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+}
+
+.model-picker-option-check {
+ flex-shrink: 0;
+ color: var(--green-text);
+}
+
+.model-picker-divider {
+ height: 1px;
+ background: var(--border);
+ margin: 0.25rem 0;
+}
+
+.model-picker-manage {
+ color: var(--blue-text);
+}
+
+.model-picker-manage:hover {
+ background: var(--bg-tertiary);
+}
+
+.model-picker-manage svg {
+ flex-shrink: 0;
+}
+
.status-input {
padding: 0.25rem 0.5rem;
border: 1px solid var(--blue-text);