feat(KB-627): Steps 1-2 — Create ModelSelectionModal and integrate with QuickEntryBox
This commit is contained in:
171
packages/dashboard/app/components/ModelSelectionModal.tsx
Normal file
171
packages/dashboard/app/components/ModelSelectionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user