From 5ce42b6bb6fb15d15c3de4ab2a2c3342bf48ce77 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Tue, 27 Jan 2026 02:49:30 +0000 Subject: [PATCH] shelley: replace model select with custom dropdown widget 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 setSelectedModel(e.target.value)} - onBlur={() => setEditingModel(false)} - disabled={sending} - className="status-select" - autoFocus - > - {models.map((model) => ( - - ))} - - ) : ( - - )} + onOpenModelsModal?.()} + disabled={sending} + /> {/* CWD indicator - far right */} diff --git a/ui/src/components/ModelPicker.tsx b/ui/src/components/ModelPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93091ab7e88d340b0661fed64709d2ac43153439 --- /dev/null +++ b/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(null); + const dropdownRef = useRef(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 ( +
+ + + {isOpen && ( +
+
+ {models.map((model) => ( + + ))} +
+
+ +
+ )} +
+ ); +} + +export default ModelPicker; diff --git a/ui/src/styles.css b/ui/src/styles.css index 9c6cc544e58efeb95ca30398479cdcc338799a43..0f6dbb891a819fd64b1e9d66f5ca3bd1e63bfb58 100644 --- a/ui/src/styles.css +++ b/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);