feat(FN-770): add quick-add favorite models to task creation surfaces

- Wire favorite model state into QuickEntryBox with star toggle and dropdown integration
- Add favorite model selector to Board and Column quick-add components
- Pass favorite models through ListView and App context to entry surfaces
- Add regression tests for QuickEntryBox, Board, Column, and ListView favorite behavior
- Update dashboard README to document quick-add favorite model support
- Remove unused recovery-policy module and dead code from executor/triage/scheduler
- Clean up unused types from core and simplify store methods
This commit is contained in:
gsxdsm
2026-04-03 08:14:16 -07:00
parent 51855f43d9
commit aa71687bb6
11 changed files with 276 additions and 17 deletions

View File

@@ -514,6 +514,10 @@ function AppInner() {
searchQuery={searchQuery}
availableModels={availableModels}
onOpenFilesForTask={handleOpenChangedFiles}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
);
}

View File

@@ -36,6 +36,10 @@ interface BoardProps {
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
}
function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -54,7 +58,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -164,6 +168,10 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
allTasks={filteredTasks}
availableModels={availableModels}
onOpenFilesForTask={onOpenFilesForTask}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -47,9 +47,13 @@ interface ColumnProps {
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -196,6 +200,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
autoExpand={true}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
)}
{column === "in-progress" ? (

View File

@@ -748,6 +748,10 @@ export function ListView({
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{filteredCount === 0 ? (

View File

@@ -29,6 +29,26 @@ interface QuickEntryBoxProps {
* Defaults to true for backward compatibility.
*/
autoExpand?: boolean;
/**
* Favorited provider IDs from shared app-level state.
* When provided (alongside availableModels), the component uses these
* instead of its own internal favorite state.
*/
favoriteProviders?: string[];
/**
* Favorited model IDs from shared app-level state.
* When provided (alongside availableModels), the component uses these
* instead of its own internal favorite state.
*/
favoriteModels?: string[];
/**
* Toggle favorite provider callback from shared app-level state.
*/
onToggleFavorite?: (provider: string) => void;
/**
* Toggle favorite model callback from shared app-level state.
*/
onToggleModelFavorite?: (modelId: string) => void;
}
function getModelSelectionValue(provider?: string, modelId?: string): string {
@@ -51,7 +71,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite }: QuickEntryBoxProps) {
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem(STORAGE_KEY) || "";
@@ -87,6 +107,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [isRefining, setIsRefining] = useState(false);
const refineMenuRef = useRef<HTMLDivElement>(null);
// Use parent-provided favorites when available, otherwise internal state
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
// If onCreate is not provided, the component is disabled
const isDisabled = !onCreate;
@@ -106,8 +130,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
.then((response) => {
if (!cancelled) {
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
// Only set internal favorites when parent doesn't manage them
if (!parentFavoriteProviders) {
setFavoriteProviders(response.favoriteProviders);
}
if (!parentFavoriteModels) {
setFavoriteModels(response.favoriteModels);
}
}
})
.catch((err: any) => {
@@ -374,6 +403,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, []);
const handleToggleFavorite = useCallback(async (provider: string) => {
// Delegate to parent callback when available
if (parentToggleFavorite) {
parentToggleFavorite(provider);
return;
}
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
@@ -388,9 +423,15 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders, favoriteModels]);
}, [favoriteProviders, favoriteModels, parentToggleFavorite]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
// Delegate to parent callback when available
if (parentToggleModelFavorite) {
parentToggleModelFavorite(modelId);
return;
}
const currentFavorites = favoriteModels;
const isFavorite = currentFavorites.includes(modelId);
const newFavorites = isFavorite
@@ -405,7 +446,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Revert on error
setFavoriteModels(currentFavorites);
}
}, [favoriteModels, favoriteProviders]);
}, [favoriteModels, favoriteProviders, parentToggleModelFavorite]);
const handlePlanClick = useCallback(() => {
const trimmed = description.trim();
@@ -473,14 +514,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
try {
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
// Only set internal favorites when parent doesn't manage them
if (!parentFavoriteProviders) {
setFavoriteProviders(response.favoriteProviders);
}
if (!parentFavoriteModels) {
setFavoriteModels(response.favoriteModels);
}
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
setModelsLoading(false);
}
}, [availableModels]);
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
@@ -713,9 +759,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
modelsLoading={modelsLoading}
modelsError={modelsError}
onRetry={loadModels}
favoriteProviders={favoriteProviders}
favoriteProviders={effectiveFavoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
favoriteModels={effectiveFavoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>,
document.body,

View File

@@ -32,7 +32,7 @@ vi.mock("../../api", async (importOriginal) => {
),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [] })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),

View File

@@ -10,10 +10,10 @@ const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, onToggleCollapse, availableModels }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; availableModels?: unknown }) => {
Column: React.memo(({ column, tasks, onToggleCollapse, availableModels, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; availableModels?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void }) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div>
);
@@ -336,4 +336,41 @@ describe("Board", () => {
const badge = document.querySelector(".board-project-context");
expect(badge).toBeNull();
});
describe("favorite model prop forwarding (FN-770)", () => {
it("forwards favoriteProviders and favoriteModels to all columns", () => {
const favoriteProviders = ["anthropic"];
const favoriteModels = ["claude-sonnet-4-5"];
const onToggleFavorite = vi.fn();
const onToggleModelFavorite = vi.fn();
renderBoard({
favoriteProviders,
favoriteModels,
onToggleFavorite,
onToggleModelFavorite,
});
// Every column should receive the favorite props
for (const col of COLUMNS) {
const columnEl = screen.getByTestId(`column-${col}`);
expect(columnEl.getAttribute("data-favorite-providers")).toBe(JSON.stringify(favoriteProviders));
expect(columnEl.getAttribute("data-favorite-models")).toBe(JSON.stringify(favoriteModels));
expect(columnEl.getAttribute("data-has-toggle-favorite")).toBe("yes");
expect(columnEl.getAttribute("data-has-toggle-model-favorite")).toBe("yes");
}
});
it("passes empty arrays for favorites when not provided", () => {
renderBoard();
for (const col of COLUMNS) {
const columnEl = screen.getByTestId(`column-${col}`);
expect(columnEl.getAttribute("data-favorite-providers")).toBe("[]");
expect(columnEl.getAttribute("data-favorite-models")).toBe("[]");
expect(columnEl.getAttribute("data-has-toggle-favorite")).toBe("no");
expect(columnEl.getAttribute("data-has-toggle-model-favorite")).toBe("no");
}
});
});
});

View File

@@ -18,7 +18,15 @@ vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: () => <div />,
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: () => <div data-testid="quick-entry-box" />,
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void }) => (
<div
data-testid="quick-entry-box"
data-favorite-providers={JSON.stringify(favoriteProviders ?? [])}
data-favorite-models={JSON.stringify(favoriteModels ?? [])}
data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"}
data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}
/>
),
}));
vi.mock("lucide-react", () => ({
Link: () => null,
@@ -261,4 +269,47 @@ describe("Column same-column drop", () => {
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "in-review");
});
describe("favorite model prop forwarding (FN-770)", () => {
it("forwards favoriteProviders, favoriteModels, and toggle callbacks to QuickEntryBox", () => {
const onToggleFavorite = vi.fn();
const onToggleModelFavorite = vi.fn();
render(
<Column
{...defaultProps}
column="triage"
tasks={[]}
onQuickCreate={vi.fn().mockResolvedValue({})}
favoriteProviders={["anthropic"]}
favoriteModels={["claude-sonnet-4-5"]}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>,
);
const quickEntry = screen.getByTestId("quick-entry-box");
expect(quickEntry.getAttribute("data-favorite-providers")).toBe(JSON.stringify(["anthropic"]));
expect(quickEntry.getAttribute("data-favorite-models")).toBe(JSON.stringify(["claude-sonnet-4-5"]));
expect(quickEntry.getAttribute("data-has-toggle-favorite")).toBe("yes");
expect(quickEntry.getAttribute("data-has-toggle-model-favorite")).toBe("yes");
});
it("passes empty favorites when props not provided", () => {
render(
<Column
{...defaultProps}
column="triage"
tasks={[]}
onQuickCreate={vi.fn().mockResolvedValue({})}
/>,
);
const quickEntry = screen.getByTestId("quick-entry-box");
expect(quickEntry.getAttribute("data-favorite-providers")).toBe("[]");
expect(quickEntry.getAttribute("data-favorite-models")).toBe("[]");
expect(quickEntry.getAttribute("data-has-toggle-favorite")).toBe("no");
expect(quickEntry.getAttribute("data-has-toggle-model-favorite")).toBe("no");
});
});
});

View File

@@ -1973,4 +1973,42 @@ describe("ListView - Bulk Selection", () => {
// The dropdown would need to be interacted with to enable the button
// This test verifies the initial disabled state and button presence
});
it("forwards favoriteProviders and favoriteModels to QuickEntryBox model modal (FN-770)", async () => {
const availableModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
];
const tasks = [createMockTask({ id: "FN-001" })];
const onToggleFavorite = vi.fn();
const onToggleModelFavorite = vi.fn();
render(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
onQuickCreate={vi.fn().mockResolvedValue(undefined)}
availableModels={availableModels}
favoriteProviders={["anthropic"]}
favoriteModels={["claude-sonnet-4-5"]}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
);
// Expand the QuickEntryBox and open the model modal
const toggleButton = screen.getByTestId("quick-entry-toggle");
fireEvent.click(toggleButton);
const modelButton = await screen.findByTestId("quick-entry-models-button");
fireEvent.click(modelButton);
// The real ModelSelectionModal should render via portal with data-testid
const modal = await screen.findByTestId("model-selection-modal");
expect(modal).toBeDefined();
// Verify the modal has content (models are loaded)
expect(modal.textContent).toContain("Select Models");
});
});

View File

@@ -95,6 +95,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading,
modelsError,
onRetry,
favoriteProviders,
onToggleFavorite,
favoriteModels,
onToggleModelFavorite,
}: {
@@ -108,6 +110,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading: boolean;
modelsError: string | null;
onRetry: () => void;
favoriteProviders?: string[];
onToggleFavorite?: (provider: string) => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
}) => {
@@ -119,6 +123,8 @@ vi.mock("../ModelSelectionModal", () => ({
<div data-testid="modal-props-validator-value">{validatorValue}</div>
<div data-testid="modal-props-loading">{modelsLoading ? "loading" : "not-loading"}</div>
<div data-testid="modal-props-error">{modelsError || "no-error"}</div>
<div data-testid="modal-props-favorite-providers">{JSON.stringify(favoriteProviders ?? [])}</div>
<div data-testid="modal-props-has-toggle-favorite">{onToggleFavorite ? "yes" : "no"}</div>
<div data-testid="modal-props-favorite-models">{JSON.stringify(favoriteModels ?? [])}</div>
<div data-testid="modal-props-has-toggle-model-favorite">{onToggleModelFavorite ? "yes" : "no"}</div>
<button data-testid="modal-close" onClick={onClose}>
@@ -612,6 +618,63 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("passes favoriteProviders and favoriteModels from parent props to ModelSelectionModal (regression FN-770)", () => {
const parentToggleFavorite = vi.fn();
const parentToggleModelFavorite = vi.fn();
renderQuickEntryBox({
favoriteProviders: ["anthropic"],
favoriteModels: ["claude-sonnet-4-5"],
onToggleFavorite: parentToggleFavorite,
onToggleModelFavorite: parentToggleModelFavorite,
});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with parent favorites" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
expect(screen.getByTestId("modal-props-favorite-providers").textContent).toBe(JSON.stringify(["anthropic"]));
expect(screen.getByTestId("modal-props-favorite-models").textContent).toBe(JSON.stringify(["claude-sonnet-4-5"]));
expect(screen.getByTestId("modal-props-has-toggle-favorite").textContent).toBe("yes");
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("delegates toggle favorite to parent callback when provided (regression FN-770)", () => {
const parentToggleFavorite = vi.fn();
const parentToggleModelFavorite = vi.fn();
renderQuickEntryBox({
favoriteProviders: ["anthropic"],
favoriteModels: ["claude-sonnet-4-5"],
onToggleFavorite: parentToggleFavorite,
onToggleModelFavorite: parentToggleModelFavorite,
});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with parent favorites" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
// The modal has toggle callbacks; simulate using them
expect(screen.getByTestId("modal-props-has-toggle-favorite").textContent).toBe("yes");
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("falls back to internal favorites when parent props not provided (standalone mode)", () => {
// availableModels is supplied but no favorite props — uses internal empty state
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Standalone task" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
// When parent doesn't provide favorites, internal state is used (empty by default)
expect(screen.getByTestId("modal-props-favorite-providers").textContent).toBe("[]");
expect(screen.getByTestId("modal-props-favorite-models").textContent).toBe("[]");
expect(screen.getByTestId("modal-props-has-toggle-favorite").textContent).toBe("yes");
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();