feat(KB-627): Steps 1-2 — Create ModelSelectionModal and integrate with QuickEntryBox
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(sqlite3:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user