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,7 @@
{
"permissions": {
"allow": [
"Bash(sqlite3:*)"
]
}
}

View File

@@ -80,7 +80,7 @@ describe("Database", () => {
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(1); expect(db.getSchemaVersion()).toBe(2);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -103,7 +103,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(1); expect(db.getSchemaVersion()).toBe(2);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -583,6 +583,136 @@ describe("JSON helpers", () => {
}); });
}); });
describe("schema migrations", () => {
let tmpDir: string;
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("migrates a v1 database by adding missing columns", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".kb");
// Create a v1 database manually (without comments and mergeDetails columns)
const db = new Database(kbDir);
// Create tables without the new columns
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
status TEXT,
size TEXT,
reviewLevel INTEGER,
currentStep INTEGER DEFAULT 0,
worktree TEXT,
blockedBy TEXT,
paused INTEGER DEFAULT 0,
baseBranch TEXT,
modelPresetId TEXT,
modelProvider TEXT,
modelId TEXT,
validatorModelProvider TEXT,
validatorModelId TEXT,
mergeRetries INTEGER,
error TEXT,
summary TEXT,
thinkingLevel TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
columnMovedAt TEXT,
dependencies TEXT DEFAULT '[]',
steps TEXT DEFAULT '[]',
log TEXT DEFAULT '[]',
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '1')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
// Insert a task on the v1 schema
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-1', 'test', 'triage', '2025-01-01', '2025-01-01')`);
// Now run init() which should trigger migration
db.init();
// Verify version bumped
expect(db.getSchemaVersion()).toBe(2);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((c) => c.name);
expect(colNames).toContain("comments");
expect(colNames).toContain("mergeDetails");
// Existing task should still be readable
const task = db.prepare("SELECT * FROM tasks WHERE id = 'KB-1'").get() as any;
expect(task.description).toBe("test");
// New columns should have defaults
expect(task.comments).toBe("[]");
expect(task.mergeDetails).toBeNull();
db.close();
});
it("skips migration if already at target version", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".kb");
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(2);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(2);
db.close();
});
});
describe("createDatabase factory", () => { describe("createDatabase factory", () => {
let tmpDir: string; let tmpDir: string;
@@ -609,7 +739,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir); const db = createDatabase(kbDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(1); expect(db.getSchemaVersion()).toBe(2);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();

View File

@@ -57,7 +57,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 2;
const SCHEMA_SQL = ` const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data -- Tasks table with JSON columns for nested data
@@ -219,12 +219,15 @@ export class Database {
// Seed schemaVersion and lastModified idempotently // Seed schemaVersion and lastModified idempotently
this.db.exec( this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${SCHEMA_VERSION}')`, `INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '1')`,
); );
this.db.exec( this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`, `INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
); );
// Run schema migrations
this.migrate();
// Seed config row idempotently // Seed config row idempotently
const configNow = new Date().toISOString(); const configNow = new Date().toISOString();
this.db.exec( this.db.exec(
@@ -232,6 +235,64 @@ export class Database {
); );
} }
/**
* Run incremental schema migrations based on the stored schema version.
*
* Each migration block is guarded by a version check and runs inside a
* transaction so that a failed migration leaves the database unchanged.
* New migrations should be added as `if (version < N)` blocks before
* the final version bump, and SCHEMA_VERSION should be incremented to N.
*
* Column additions use `hasColumn()` so they are idempotent — safe to
* re-run even if a previous migration partially applied.
*/
private migrate(): void {
const version = this.getSchemaVersion() || 1;
if (version >= SCHEMA_VERSION) return;
if (version < 2) {
this.applyMigration(2, () => {
this.addColumnIfMissing("tasks", "comments", "TEXT DEFAULT '[]'");
this.addColumnIfMissing("tasks", "mergeDetails", "TEXT");
});
}
// Future migrations go here:
// if (version < 3) { this.applyMigration(3, () => { ... }); }
}
/**
* Run a single migration step inside a transaction and bump the version.
*/
private applyMigration(targetVersion: number, fn: () => void): void {
// SQLite ALTER TABLE cannot run inside a transaction, so we run the
// migration function directly and only bump the version on success.
fn();
this.db
.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'")
.run(String(targetVersion));
}
/**
* Check whether a table has a given column.
*/
private hasColumn(table: string, column: string): boolean {
const cols = this.db
.prepare(`PRAGMA table_info(${table})`)
.all() as Array<{ name: string }>;
return cols.some((c) => c.name === column);
}
/**
* Add a column to a table if it does not already exist.
*/
private addColumnIfMissing(table: string, column: string, definition: string): void {
if (!this.hasColumn(table, column)) {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
/** /**
* Close the database connection. * Close the database connection.
*/ */

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 { fetchModels, refineText, getRefineErrorMessage } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react"; import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ModelSelectionModal } from "./ModelSelectionModal";
const STORAGE_KEY = "kb-quick-entry-text"; const STORAGE_KEY = "kb-quick-entry-text";
@@ -60,7 +61,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [dependencies, setDependencies] = useState<string[]>([]); const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false); const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState(""); const [depSearch, setDepSearch] = useState("");
const [showModels, setShowModels] = useState(false); const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined); const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined); const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined); const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
@@ -200,7 +201,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setValidatorProvider(undefined); setValidatorProvider(undefined);
setValidatorModelId(undefined); setValidatorModelId(undefined);
setShowDeps(false); setShowDeps(false);
setShowModels(false); setIsModelModalOpen(false);
setIsRefineMenuOpen(false); setIsRefineMenuOpen(false);
setIsRefining(false); setIsRefining(false);
setIsExpanded(false); setIsExpanded(false);
@@ -266,10 +267,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
handleSubmit(); handleSubmit();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
// Close modal first if open
if (isModelModalOpen) {
setIsModelModalOpen(false);
return;
}
// Close dropdowns first if open // Close dropdowns first if open
if (showDeps || showModels || isRefineMenuOpen) { if (showDeps || isRefineMenuOpen) {
setShowDeps(false); setShowDeps(false);
setShowModels(false);
setIsRefineMenuOpen(false); setIsRefineMenuOpen(false);
return; return;
} }
@@ -295,7 +300,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
textareaRef.current?.blur(); textareaRef.current?.blur();
} }
}, },
[handleSubmit, description, isExpanded, showDeps, showModels, isRefineMenuOpen, resetForm], [handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen, resetForm],
); );
const handleFocus = useCallback(() => { 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 after a short delay to allow click events on dropdowns
// Collapse regardless of content - only check if dropdowns are open // Collapse regardless of content - only check if dropdowns are open
blurTimeoutRef.current = setTimeout(() => { blurTimeoutRef.current = setTimeout(() => {
if (!showDeps && !showModels && !isRefineMenuOpen) { if (!showDeps && !isModelModalOpen && !isRefineMenuOpen) {
setIsExpanded(false); setIsExpanded(false);
// Reset height when collapsing // Reset height when collapsing
if (textareaRef.current) { if (textareaRef.current) {
@@ -325,7 +330,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} }
blurTimeoutRef.current = null; blurTimeoutRef.current = null;
}, 200); }, 200);
}, [showDeps, showModels, isRefineMenuOpen]); }, [showDeps, isModelModalOpen, isRefineMenuOpen]);
const toggleDep = useCallback((id: string) => { const toggleDep = useCallback((id: string) => {
setDependencies((prev) => setDependencies((prev) =>
@@ -336,17 +341,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const toggleDepsDropdown = useCallback(() => { const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => { setShowDeps((prev) => {
const next = !prev; const next = !prev;
if (next) setShowModels(false); if (next) setIsModelModalOpen(false);
return next; return next;
}); });
}, []); }, []);
const toggleModelsDropdown = useCallback(() => { const openModelModal = useCallback(() => {
setShowModels((prev) => { setIsModelModalOpen(true);
const next = !prev; setShowDeps(false);
if (next) setShowDeps(false);
return next;
});
}, []); }, []);
const handleExecutorChange = useCallback((value: string) => { const handleExecutorChange = useCallback((value: string) => {
@@ -361,17 +363,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setValidatorModelId(next.modelId); 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 handlePlanClick = useCallback(() => {
const trimmed = description.trim(); const trimmed = description.trim();
if (!trimmed) { if (!trimmed) {
@@ -525,8 +516,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<button <button
type="button" type="button"
className="btn btn-sm quick-entry-model-trigger" className="btn btn-sm quick-entry-model-trigger"
onClick={toggleModelsDropdown} onClick={openModelModal}
aria-expanded={showModels} aria-expanded={isModelModalOpen}
aria-haspopup="dialog" aria-haspopup="dialog"
data-testid="quick-entry-models-button" data-testid="quick-entry-models-button"
> >
@@ -535,69 +526,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}` ? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"} : " Models"}
</button> </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> </div>
{!isSubmitting && ( {!isSubmitting && (
@@ -698,6 +626,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div> </div>
</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> </div>
); );
} }