shelley: replace model select with custom dropdown widget

Philip Zeyliger and Shelley created

Prompt: make the model picker a custom widget instead of a selection box,
and have one of the options be the add/remove custom models, so that users
can find that menu directly.

Replace the native <select> element for model selection with a custom
dropdown widget (ModelPicker). The new widget:

- Shows all available models with checkmarks for the selected one
- Opens upward when there's not enough space below
- Includes an 'Add / Remove Models...' option that opens the models modal
- Provides better discoverability for the custom models feature

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/src/App.tsx                      |   1 
ui/src/components/ChatInterface.tsx |  36 ++-----
ui/src/components/ModelPicker.tsx   | 153 +++++++++++++++++++++++++++++++
ui/src/styles.css                   | 150 ++++++++++++++++++++++++++++++
4 files changed, 314 insertions(+), 26 deletions(-)

Detailed changes

ui/src/App.tsx 🔗

@@ -382,6 +382,7 @@ function App() {
           onToggleDrawerCollapse={toggleDrawerCollapsed}
           openDiffViewerTrigger={diffViewerTrigger}
           modelsRefreshTrigger={modelsRefreshTrigger}
+          onOpenModelsModal={() => setModelsModalOpen(true)}
         />
       </div>
 

ui/src/components/ChatInterface.tsx 🔗

@@ -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 */}

ui/src/components/ModelPicker.tsx 🔗

@@ -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;

ui/src/styles.css 🔗

@@ -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);