feat(KB-627): Steps 1-2 — Create ModelSelectionModal and integrate with QuickEntryBox

This commit is contained in:
gsxdsm
2026-03-31 17:51:20 -07:00
parent dbfb8bc1d9
commit be5a7d9b3f
5 changed files with 405 additions and 95 deletions

View File

@@ -0,0 +1,171 @@
import { useCallback, useEffect } from "react";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { Brain, X } from "lucide-react";
interface ModelSelectionModalProps {
isOpen: boolean;
onClose: () => void;
models: ModelInfo[];
executorValue: string;
validatorValue: string;
onExecutorChange: (value: string) => void;
onValidatorChange: (value: string) => void;
modelsLoading: boolean;
modelsError: string | null;
onRetry: () => void;
}
function getModelBadgeLabel(models: ModelInfo[], value: string): string {
if (!value) return "Using default";
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const matched = models.find((m) => m.provider === provider && m.id === modelId);
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
}
export function ModelSelectionModal({
isOpen,
onClose,
models,
executorValue,
validatorValue,
onExecutorChange,
onValidatorChange,
modelsLoading,
modelsError,
onRetry,
}: ModelSelectionModalProps) {
// Handle Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Handle overlay click
const handleOverlayClick = useCallback(
(event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
},
[onClose],
);
if (!isOpen) return null;
const hasExecutorOverride = Boolean(executorValue);
const hasValidatorOverride = Boolean(validatorValue);
return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="model-selection-modal">
<div className="modal modal-lg">
<div className="modal-header">
<div className="detail-title-row">
<Brain size={20} style={{ color: "var(--todo)" }} />
<h3>Select Models</h3>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close" data-testid="model-selection-close">
<X size={20} />
</button>
</div>
<div className="planning-modal-body">
{modelsLoading ? (
<div className="planning-loading">
<div className="detail-section">
<p className="text-muted">Loading models</p>
</div>
</div>
) : modelsError ? (
<div className="detail-section">
<div className="form-error planning-error">
<span>{modelsError}</span>
</div>
<button type="button" className="btn btn-sm" onClick={onRetry} data-testid="model-selection-retry">
Retry
</button>
</div>
) : models.length === 0 ? (
<div className="detail-section">
<div className="inline-create-model-empty">
No models available. Configure authentication in Settings to enable model selection.
</div>
</div>
) : (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
<div className="planning-summary-header">
<p className="text-muted">Choose models for this task. If not selected, default models will be used.</p>
</div>
<div className="planning-summary-form">
<div className="task-detail-section">
<div className="inline-create-model-row">
<label htmlFor="model-selection-executor" className="inline-create-model-label">
Executor Model
</label>
<span
className={`model-badge ${hasExecutorOverride ? "model-badge-custom" : "model-badge-default"}`}
data-testid="executor-badge"
>
{getModelBadgeLabel(models, executorValue)}
</span>
<CustomModelDropdown
id="model-selection-executor"
label="Executor Model"
value={executorValue}
onChange={onExecutorChange}
models={models}
placeholder="Select executor model…"
/>
</div>
</div>
<div className="task-detail-section">
<div className="inline-create-model-row">
<label htmlFor="model-selection-validator" className="inline-create-model-label">
Validator Model
</label>
<span
className={`model-badge ${hasValidatorOverride ? "model-badge-custom" : "model-badge-default"}`}
data-testid="validator-badge"
>
{getModelBadgeLabel(models, validatorValue)}
</span>
<CustomModelDropdown
id="model-selection-validator"
label="Validator Model"
value={validatorValue}
onChange={onValidatorChange}
models={models}
placeholder="Select validator model…"
/>
</div>
</div>
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onClose} data-testid="model-selection-done">
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type { ModelInfo, RefinementType } from "../api";
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ModelSelectionModal } from "./ModelSelectionModal";
const STORAGE_KEY = "kb-quick-entry-text";
@@ -60,7 +61,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [showModels, setShowModels] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
@@ -200,7 +201,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setShowDeps(false);
setShowModels(false);
setIsModelModalOpen(false);
setIsRefineMenuOpen(false);
setIsRefining(false);
setIsExpanded(false);
@@ -266,10 +267,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
// Close modal first if open
if (isModelModalOpen) {
setIsModelModalOpen(false);
return;
}
// Close dropdowns first if open
if (showDeps || showModels || isRefineMenuOpen) {
if (showDeps || isRefineMenuOpen) {
setShowDeps(false);
setShowModels(false);
setIsRefineMenuOpen(false);
return;
}
@@ -295,7 +300,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
textareaRef.current?.blur();
}
},
[handleSubmit, description, isExpanded, showDeps, showModels, isRefineMenuOpen, resetForm],
[handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen, resetForm],
);
const handleFocus = useCallback(() => {
@@ -316,7 +321,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Collapse after a short delay to allow click events on dropdowns
// Collapse regardless of content - only check if dropdowns are open
blurTimeoutRef.current = setTimeout(() => {
if (!showDeps && !showModels && !isRefineMenuOpen) {
if (!showDeps && !isModelModalOpen && !isRefineMenuOpen) {
setIsExpanded(false);
// Reset height when collapsing
if (textareaRef.current) {
@@ -325,7 +330,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
blurTimeoutRef.current = null;
}, 200);
}, [showDeps, showModels, isRefineMenuOpen]);
}, [showDeps, isModelModalOpen, isRefineMenuOpen]);
const toggleDep = useCallback((id: string) => {
setDependencies((prev) =>
@@ -336,17 +341,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => {
const next = !prev;
if (next) setShowModels(false);
if (next) setIsModelModalOpen(false);
return next;
});
}, []);
const toggleModelsDropdown = useCallback(() => {
setShowModels((prev) => {
const next = !prev;
if (next) setShowDeps(false);
return next;
});
const openModelModal = useCallback(() => {
setIsModelModalOpen(true);
setShowDeps(false);
}, []);
const handleExecutorChange = useCallback((value: string) => {
@@ -361,17 +363,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setValidatorModelId(next.modelId);
}, []);
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target;
if (
target instanceof HTMLElement &&
(target.closest("button") || target.closest("input"))
) {
return;
}
e.preventDefault();
}, []);
const handlePlanClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
@@ -525,8 +516,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<button
type="button"
className="btn btn-sm quick-entry-model-trigger"
onClick={toggleModelsDropdown}
aria-expanded={showModels}
onClick={openModelModal}
aria-expanded={isModelModalOpen}
aria-haspopup="dialog"
data-testid="quick-entry-models-button"
>
@@ -535,69 +526,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
</button>
{showModels && (
<div
className="inline-create-model-dropdown"
onMouseDown={handleModelDropdownMouseDown}
>
{modelsLoading ? (
<div className="inline-create-model-empty">Loading models</div>
) : modelsError ? (
<div className="inline-create-model-empty">
<span>Failed to load models.</span>
<button
type="button"
className="btn btn-sm"
onClick={() => void loadModels()}
>
Retry
</button>
</div>
) : loadedModels.length === 0 ? (
<div className="inline-create-model-empty">
No models available. Configure authentication in Settings to enable model selection.
</div>
) : (
<>
<div className="inline-create-model-row">
<label htmlFor="quick-entry-executor-model" className="inline-create-model-label">
Executor Model
</label>
<span className={`model-badge ${hasExecutorOverride ? "model-badge-custom" : "model-badge-default"}`}>
{getModelBadgeLabel(executorProvider, executorModelId)}
</span>
<CustomModelDropdown
id="quick-entry-executor-model"
label="Executor Model"
value={executorSelectionValue}
onChange={handleExecutorChange}
models={loadedModels}
disabled={isSubmitting}
placeholder="Select executor model…"
/>
</div>
<div className="inline-create-model-row">
<label htmlFor="quick-entry-validator-model" className="inline-create-model-label">
Validator Model
</label>
<span className={`model-badge ${hasValidatorOverride ? "model-badge-custom" : "model-badge-default"}`}>
{getModelBadgeLabel(validatorProvider, validatorModelId)}
</span>
<CustomModelDropdown
id="quick-entry-validator-model"
label="Validator Model"
value={validatorSelectionValue}
onChange={handleValidatorChange}
models={loadedModels}
disabled={isSubmitting}
placeholder="Select validator model…"
/>
</div>
</>
)}
</div>
)}
</div>
{!isSubmitting && (
@@ -698,6 +626,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div>
</div>
)}
<ModelSelectionModal
isOpen={isModelModalOpen}
onClose={() => setIsModelModalOpen(false)}
models={loadedModels}
executorValue={executorSelectionValue}
validatorValue={validatorSelectionValue}
onExecutorChange={handleExecutorChange}
onValidatorChange={handleValidatorChange}
modelsLoading={modelsLoading}
modelsError={modelsError}
onRetry={loadModels}
/>
</div>
);
}