feat: rename data directory, add global project settings, multi-project CLI commands, and provider badge in model selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-02 09:10:42 -07:00
parent 1fa8837c6b
commit 566f531361
80 changed files with 857 additions and 652 deletions

View File

@@ -122,7 +122,7 @@ export function ListView({
// Invalid localStorage data - fall through to default
}
}
return true; // Default: hide done tasks
return false; // Default: show done tasks
});
// Collapsed sections state - initialize from localStorage

View File

@@ -13,7 +13,7 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
*
* Each section groups related settings fields under a sidebar nav item.
* Sections have a `scope` to indicate where their settings are stored:
* - "global": User-level settings stored in ~/.pi/kb/settings.json (shared across projects)
* - "global": User-level settings stored in ~/.pi/fusion/settings.json (shared across projects)
* - "project": Project-specific settings stored in .fusion/config.json
* - undefined: Section operates independently of settings storage (e.g. authentication)
*

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { App } from "../../App";
import type { Settings } from "@fusion/core";
const defaultSettings: Settings = {
@@ -36,6 +35,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [] })),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
};
});
@@ -78,6 +78,7 @@ vi.mock("../../hooks/useCurrentProject", () => ({
}),
}));
import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from "../../api";
beforeEach(() => {
@@ -134,11 +135,7 @@ describe("App deep link handling", () => {
expect(screen.getByText("Task FN-123")).toBeTruthy();
});
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
"",
"http://localhost:3000/",
);
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it("shows an error toast when the deep-linked task cannot be loaded", async () => {
@@ -557,7 +554,7 @@ describe("App view switching", () => {
});
it("persists agents view preference to localStorage", async () => {
localStorage.removeItem("kb-dashboard-view");
localStorage.removeItem("kb-dashboard-task-view");
render(<App />);
@@ -568,12 +565,12 @@ describe("App view switching", () => {
fireEvent.click(screen.getByTitle("Agents view"));
await waitFor(() => {
expect(localStorage.getItem("kb-dashboard-view")).toBe("agents");
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("agents");
});
});
it("initializes agents view from localStorage if saved", async () => {
localStorage.setItem("kb-dashboard-view", "agents");
localStorage.setItem("kb-dashboard-task-view", "agents");
render(<App />);
@@ -583,7 +580,7 @@ describe("App view switching", () => {
expect(screen.getByTitle("Agents view").className).toContain("active");
localStorage.removeItem("kb-dashboard-view");
localStorage.removeItem("kb-dashboard-task-view");
});
});

View File

@@ -1,5 +1,5 @@
import type { ComponentProps } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@fusion/core";
@@ -23,11 +23,16 @@ vi.mock("lucide-react", () => ({
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
uploadAttachment: vi.fn(),
updateGlobalSettings: vi.fn(),
@@ -83,9 +88,18 @@ function renderCard(
}
function openModelPanel() {
// Models button is inside expanded section - expand first if not already
const modelsButton = screen.queryByRole("button", { name: /Models/i });
if (!modelsButton) {
expandCard();
}
fireEvent.click(screen.getByRole("button", { name: /Models/i }));
}
function expandCard() {
fireEvent.click(screen.getByTestId("inline-create-toggle"));
}
function chooseModel(label: "Executor Model" | "Validator Model", optionText: string) {
fireEvent.click(screen.getByRole("button", { name: label }));
fireEvent.click(screen.getByText(optionText));
@@ -94,11 +108,16 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [] });
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
});
});
@@ -125,6 +144,7 @@ describe("InlineCreateCard blur-to-cancel", () => {
it("does NOT call onCancel when focus moves to another element inside the card", () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
const depsButton = screen.getByText(/Deps/);
@@ -152,6 +172,7 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
renderCard(testTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
@@ -162,6 +183,7 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
const { props } = renderCard(testTasks);
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByText(/Deps/));
@@ -249,6 +271,7 @@ describe("InlineCreateCard model selector", () => {
it("includes selected models in the submit payload", async () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
@@ -272,6 +295,7 @@ describe("InlineCreateCard model selector", () => {
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -286,8 +310,14 @@ describe("InlineCreateCard model selector", () => {
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
});
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
@@ -302,8 +332,14 @@ describe("InlineCreateCard model selector", () => {
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
});
const { props } = renderCard([], { availableModels: undefined });
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with preset" } });
@@ -317,14 +353,13 @@ describe("InlineCreateCard model selector", () => {
modelPresetId: "budget",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}));
});
});
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
it("calls onCancel after a model override is selected and focus leaves the card", () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -334,11 +369,12 @@ describe("InlineCreateCard model selector", () => {
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it("prevents default on model option mouseDown to retain focus while selecting", () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
@@ -369,7 +405,7 @@ describe("InlineCreateCard model selector", () => {
it("shows an error state and retries model loading", async () => {
vi.mocked(fetchModels)
.mockRejectedValueOnce(new Error("no auth"))
.mockResolvedValueOnce(MOCK_MODELS);
.mockResolvedValueOnce({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
renderCard([], { availableModels: undefined });
openModelPanel();
@@ -396,6 +432,7 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
renderCard(scrambledTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -405,6 +442,7 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("preserves newest-first sort order when a search filter is applied", () => {
renderCard(scrambledTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -424,6 +462,7 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
renderCard(sameTimeTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -433,6 +472,7 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
renderCard(sameTimeTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -452,6 +492,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("shows search input when dropdown is opened", () => {
renderCard(testTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(input).toBeTruthy();
@@ -460,6 +501,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("filters tasks by search term", () => {
renderCard(testTasks);
expandCard();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "dark" } });
@@ -473,6 +515,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
describe("InlineCreateCard Plan and Subtask buttons", () => {
it("renders Plan and Subtask buttons disabled when description is empty", () => {
renderCard();
expandCard();
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
expect(planButton.disabled).toBe(true);
@@ -481,6 +524,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("enables Plan and Subtask buttons when description is entered", () => {
renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Test task" } });
@@ -493,6 +537,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
const onPlanningMode = vi.fn();
renderCard([], { onPlanningMode });
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
@@ -505,6 +550,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
const onSubtaskBreakdown = vi.fn();
renderCard([], { onSubtaskBreakdown });
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Break this down" } });
@@ -518,6 +564,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
const addToast = vi.fn();
const onPlanningMode = vi.fn();
renderCard([], { addToast, onPlanningMode });
expandCard();
// When no description, button is disabled - verify that behavior
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
@@ -531,6 +578,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
const addToast = vi.fn();
const onSubtaskBreakdown = vi.fn();
renderCard([], { addToast, onSubtaskBreakdown });
expandCard();
// When no description, button is disabled - verify that behavior
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
@@ -576,6 +624,7 @@ describe("InlineCreateCard localStorage persistence", () => {
it("clears localStorage after successful task creation", async () => {
const { props } = renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Type something to set localStorage

View File

@@ -219,7 +219,7 @@ describe("ListView", () => {
it("sorts tasks by column when Column header is clicked", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "done" }),
createMockTask({ id: "FN-001", column: "todo" }),
createMockTask({ id: "FN-002", column: "triage" }),
createMockTask({ id: "FN-003", column: "in-progress" }),
];
@@ -229,11 +229,11 @@ describe("ListView", () => {
const columnHeader = screen.getByText("Column");
fireEvent.click(columnHeader);
// Get data rows - sorted by column alphabetically: done, in-progress, triage
// Rows are rendered in fixed column-section order.
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
expect(rows[0].textContent).toContain("FN-002"); // triage (sorted first alphabetically)
expect(rows[1].textContent).toContain("FN-003"); // in-progress
expect(rows[2].textContent).toContain("FN-001"); // done
expect(rows[0].textContent).toContain("FN-002"); // triage section first
expect(rows[1].textContent).toContain("FN-001"); // todo section second
expect(rows[2].textContent).toContain("FN-003"); // in-progress section third
});
it("sorts tasks by status when Status header is clicked", () => {