feat(KB-185): add global/project settings hierarchy with scope-aware UI
- Add GlobalSettingsStore with file-based storage in ~/.pi/kb/settings.json - Implement two-tier hierarchy: global (user prefs) and project (.kb/config.json) - Update TaskStore to merge settings with project overriding global - Add API endpoints: GET/PUT /api/settings/global and GET /api/settings/scopes - Add scope indicators (🌐 global, 📁 project) to settings modal sidebar - Implement scope-aware save: only save active scope, reject global-only fields - Add frontend API functions for global settings operations - Add tests for GlobalSettingsStore, settings routes, and SettingsModal scopes - Create changeset for the new settings hierarchy feature - Update AGENTS.md with settings hierarchy documentation
This commit is contained in:
5
.changeset/global-project-settings.md
Normal file
5
.changeset/global-project-settings.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add two-tier settings hierarchy: global user settings (~/.pi/kb/settings.json) and project-specific settings (.kb/config.json). Global settings (theme, default AI model, notifications) persist across all kb projects. Dashboard UI shows scope indicators and routes saves to the correct scope.
|
||||||
33
AGENTS.md
33
AGENTS.md
@@ -105,7 +105,38 @@ Use `useBadgeWebSocket()` when a UI surface needs live badge snapshots for speci
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
The following settings are available in the kb configuration (stored in `.kb/config.json`):
|
kb uses a two-tier settings hierarchy:
|
||||||
|
|
||||||
|
- **Global settings** — User preferences stored in `~/.pi/kb/settings.json`. These persist across all kb projects for the current user.
|
||||||
|
- **Project settings** — Project-specific workflow and resource settings stored in `.kb/config.json`. These control how the engine operates for a particular project.
|
||||||
|
|
||||||
|
When reading settings, project values override global values. The merged view is what the engine and dashboard use.
|
||||||
|
|
||||||
|
### Settings Hierarchy
|
||||||
|
|
||||||
|
**Global settings** (`~/.pi/kb/settings.json`):
|
||||||
|
- `themeMode` — UI theme preference (dark/light/system)
|
||||||
|
- `colorTheme` — Color theme (default/ocean/forest/etc)
|
||||||
|
- `defaultProvider` — Default AI model provider
|
||||||
|
- `defaultModelId` — Default AI model ID
|
||||||
|
- `defaultThinkingLevel` — Default thinking effort level
|
||||||
|
- `ntfyEnabled` — Enable push notifications
|
||||||
|
- `ntfyTopic` — ntfy.sh topic for notifications
|
||||||
|
|
||||||
|
**Project settings** (`~/.kb/config.json`):
|
||||||
|
- All other settings listed below (concurrency, merge, worktrees, commands, etc.)
|
||||||
|
|
||||||
|
The dashboard Settings modal shows scope indicators (🌐 global, 📁 project) in the sidebar to help users understand where each setting is stored. Saving only updates the scope matching the active section.
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/settings` — Returns the merged view (project overrides global)
|
||||||
|
- `PUT /api/settings` — Updates project-level settings only (rejects global-only fields with 400)
|
||||||
|
- `GET /api/settings/global` — Returns global settings
|
||||||
|
- `PUT /api/settings/global` — Updates global settings
|
||||||
|
- `GET /api/settings/scopes` — Returns settings separated by scope: `{ global, project }`
|
||||||
|
|
||||||
|
The following settings are available in the kb configuration:
|
||||||
|
|
||||||
### `autoResolveConflicts` (default: `true`)
|
### `autoResolveConflicts` (default: `true`)
|
||||||
|
|
||||||
|
|||||||
185
packages/core/src/global-settings.test.ts
Normal file
185
packages/core/src/global-settings.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { GlobalSettingsStore } from "./global-settings.js";
|
||||||
|
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||||
|
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GlobalSettingsStore", () => {
|
||||||
|
let dir: string;
|
||||||
|
let store: GlobalSettingsStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = makeTmpDir();
|
||||||
|
store = new GlobalSettingsStore(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("init()", () => {
|
||||||
|
it("creates the directory and settings.json if missing", async () => {
|
||||||
|
const nested = join(dir, "nested", "deep");
|
||||||
|
const nestedStore = new GlobalSettingsStore(nested);
|
||||||
|
|
||||||
|
const created = await nestedStore.init();
|
||||||
|
|
||||||
|
expect(created).toBe(true);
|
||||||
|
expect(existsSync(join(nested, "settings.json"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates settings.json with defaults on first init", async () => {
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.themeMode).toBe("dark");
|
||||||
|
expect(parsed.colorTheme).toBe("default");
|
||||||
|
expect(parsed.ntfyEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if settings.json already exists", async () => {
|
||||||
|
await store.init(); // creates file
|
||||||
|
const created = await store.init(); // second call
|
||||||
|
expect(created).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing settings on re-init", async () => {
|
||||||
|
await store.init();
|
||||||
|
await store.updateSettings({ themeMode: "light" });
|
||||||
|
|
||||||
|
const created = await store.init();
|
||||||
|
expect(created).toBe(false);
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.themeMode).toBe("light");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettings()", () => {
|
||||||
|
it("returns defaults when file does not exist", async () => {
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
|
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns persisted values merged with defaults", async () => {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "settings.json"),
|
||||||
|
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
|
expect(settings.themeMode).toBe("light");
|
||||||
|
expect(settings.colorTheme).toBe("ocean");
|
||||||
|
// Defaults are filled in for missing fields
|
||||||
|
expect(settings.ntfyEnabled).toBe(false);
|
||||||
|
expect(settings.defaultProvider).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults on invalid JSON", async () => {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, "settings.json"), "not-json{{{");
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
|
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults when directory does not exist", async () => {
|
||||||
|
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
|
||||||
|
const settings = await nonExistent.getSettings();
|
||||||
|
|
||||||
|
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateSettings()", () => {
|
||||||
|
it("persists a partial update and returns merged settings", async () => {
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const updated = await store.updateSettings({ themeMode: "system" });
|
||||||
|
|
||||||
|
expect(updated.themeMode).toBe("system");
|
||||||
|
expect(updated.colorTheme).toBe("default"); // unchanged default
|
||||||
|
|
||||||
|
// Verify persistence
|
||||||
|
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.themeMode).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges multiple updates without losing fields", async () => {
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
|
||||||
|
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.defaultProvider).toBe("anthropic");
|
||||||
|
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
||||||
|
expect(settings.ntfyEnabled).toBe(true);
|
||||||
|
expect(settings.ntfyTopic).toBe("my-topic");
|
||||||
|
expect(settings.themeMode).toBe("dark"); // preserved default
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates directory if missing", async () => {
|
||||||
|
const nested = join(dir, "auto", "create");
|
||||||
|
const nestedStore = new GlobalSettingsStore(nested);
|
||||||
|
|
||||||
|
await nestedStore.updateSettings({ themeMode: "light" });
|
||||||
|
|
||||||
|
expect(existsSync(join(nested, "settings.json"))).toBe(true);
|
||||||
|
const settings = await nestedStore.getSettings();
|
||||||
|
expect(settings.themeMode).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can clear a field by setting it to undefined", async () => {
|
||||||
|
await store.init();
|
||||||
|
await store.updateSettings({ defaultProvider: "anthropic" });
|
||||||
|
await store.updateSettings({ defaultProvider: undefined });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.defaultProvider).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles concurrent updates safely via locking", async () => {
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
// Fire 10 concurrent updates
|
||||||
|
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
store.updateSettings({ ntfyTopic: `topic-${i}` }),
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// The final value should be one of the submitted values (last writer wins)
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettingsPath()", () => {
|
||||||
|
it("returns the path to settings.json", () => {
|
||||||
|
const path = store.getSettingsPath();
|
||||||
|
expect(path).toBe(join(dir, "settings.json"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("atomic writes", () => {
|
||||||
|
it("does not leave tmp files after a successful write", async () => {
|
||||||
|
await store.init();
|
||||||
|
await store.updateSettings({ themeMode: "light" });
|
||||||
|
|
||||||
|
const tmpPath = join(dir, "settings.json.tmp");
|
||||||
|
expect(existsSync(tmpPath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
122
packages/core/src/global-settings.ts
Normal file
122
packages/core/src/global-settings.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Global settings store — manages user-level settings in `~/.pi/kb/settings.json`.
|
||||||
|
*
|
||||||
|
* Global settings persist across all kb projects for the current user.
|
||||||
|
* They include UI theme preferences, default AI model selection, and
|
||||||
|
* notification configuration.
|
||||||
|
*
|
||||||
|
* @see {@link GlobalSettings} for the full list of global fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import type { GlobalSettings } from "./types.js";
|
||||||
|
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||||
|
|
||||||
|
/** Default directory for global kb settings: `~/.pi/kb/` */
|
||||||
|
function defaultGlobalDir(): string {
|
||||||
|
return join(homedir(), ".pi", "kb");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalSettingsStore {
|
||||||
|
private readonly settingsPath: string;
|
||||||
|
private readonly dir: string;
|
||||||
|
|
||||||
|
/** Promise chain for serializing read-modify-write cycles */
|
||||||
|
private lock: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a GlobalSettingsStore.
|
||||||
|
* @param dir — Directory to store settings.json. Defaults to `~/.pi/kb/`.
|
||||||
|
* Accepts a custom path for testing.
|
||||||
|
*/
|
||||||
|
constructor(dir?: string) {
|
||||||
|
this.dir = dir ?? defaultGlobalDir();
|
||||||
|
this.settingsPath = join(this.dir, "settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the settings directory exists. Creates it recursively if needed.
|
||||||
|
* If the settings file doesn't exist, creates it with defaults.
|
||||||
|
* Returns true if the file was created for the first time.
|
||||||
|
*/
|
||||||
|
async init(): Promise<boolean> {
|
||||||
|
await mkdir(this.dir, { recursive: true });
|
||||||
|
if (!existsSync(this.settingsPath)) {
|
||||||
|
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read global settings from disk. Returns defaults merged with persisted values.
|
||||||
|
* If the file doesn't exist or is invalid, returns defaults without throwing.
|
||||||
|
*/
|
||||||
|
async getSettings(): Promise<GlobalSettings> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(this.settingsPath, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as Partial<GlobalSettings>;
|
||||||
|
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed };
|
||||||
|
} catch {
|
||||||
|
// File missing, unreadable, or invalid JSON → return defaults
|
||||||
|
return { ...DEFAULT_GLOBAL_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update global settings by merging a partial patch into the existing values.
|
||||||
|
* Only fields present in the patch are overwritten; other fields are preserved.
|
||||||
|
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
|
||||||
|
*
|
||||||
|
* @returns The full updated settings after merge.
|
||||||
|
*/
|
||||||
|
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||||
|
return this.withLock(async () => {
|
||||||
|
const current = await this.getSettings();
|
||||||
|
const updated = { ...current, ...patch };
|
||||||
|
await mkdir(this.dir, { recursive: true });
|
||||||
|
await this.atomicWrite(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the settings file (useful for diagnostics/logging).
|
||||||
|
*/
|
||||||
|
getSettingsPath(): string {
|
||||||
|
return this.settingsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically write settings to disk. Writes to a temp file first,
|
||||||
|
* then renames into place (atomic on POSIX).
|
||||||
|
*/
|
||||||
|
private async atomicWrite(settings: GlobalSettings): Promise<void> {
|
||||||
|
const tmpPath = this.settingsPath + ".tmp";
|
||||||
|
await writeFile(tmpPath, JSON.stringify(settings, null, 2));
|
||||||
|
await rename(tmpPath, this.settingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize operations via promise chain to prevent lost-update races.
|
||||||
|
*/
|
||||||
|
private withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
let resolve: () => void;
|
||||||
|
const next = new Promise<void>((r) => { resolve = r; });
|
||||||
|
const prev = this.lock;
|
||||||
|
this.lock = next;
|
||||||
|
|
||||||
|
return prev.then(async () => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
resolve!();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
|
export { GlobalSettingsStore } from "./global-settings.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
export {
|
export {
|
||||||
isGhAvailable,
|
isGhAvailable,
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ function makeTmpDir(): string {
|
|||||||
|
|
||||||
describe("TaskStore", () => {
|
describe("TaskStore", () => {
|
||||||
let rootDir: string;
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
rootDir = makeTmpDir();
|
rootDir = makeTmpDir();
|
||||||
store = new TaskStore(rootDir);
|
globalDir = makeTmpDir();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
await store.init();
|
await store.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
store.stopWatching();
|
store.stopWatching();
|
||||||
await rm(rootDir, { recursive: true, force: true });
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
await rm(globalDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createTestTask(): Promise<Task> {
|
async function createTestTask(): Promise<Task> {
|
||||||
@@ -356,8 +359,8 @@ describe("TaskStore", () => {
|
|||||||
// ── Settings tests ────────────────────────────────────────────────
|
// ── Settings tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("model settings", () => {
|
describe("model settings", () => {
|
||||||
it("persists defaultProvider and defaultModelId and returns them via getSettings", async () => {
|
it("persists defaultProvider and defaultModelId via updateGlobalSettings", async () => {
|
||||||
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
|
await store.updateGlobalSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.defaultProvider).toBe("anthropic");
|
expect(settings.defaultProvider).toBe("anthropic");
|
||||||
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
||||||
@@ -409,6 +412,111 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Global/Project Settings Merging ─────────────────────────────
|
||||||
|
|
||||||
|
describe("global/project settings merging", () => {
|
||||||
|
it("getSettings returns global defaults when no overrides exist", async () => {
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.themeMode).toBe("dark");
|
||||||
|
expect(settings.colorTheme).toBe("default");
|
||||||
|
expect(settings.maxConcurrent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global settings are visible through getSettings", async () => {
|
||||||
|
await store.updateGlobalSettings({ themeMode: "light", colorTheme: "ocean" });
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.themeMode).toBe("light");
|
||||||
|
expect(settings.colorTheme).toBe("ocean");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("project settings override global defaults", async () => {
|
||||||
|
await store.updateSettings({ maxConcurrent: 8 });
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.maxConcurrent).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateSettings silently filters out global-only fields", async () => {
|
||||||
|
// themeMode is a global field — should not be persisted to project config
|
||||||
|
await store.updateSettings({ maxConcurrent: 5, themeMode: "light" } as any);
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.maxConcurrent).toBe(5);
|
||||||
|
// themeMode should still be the global default, not "light"
|
||||||
|
expect(settings.themeMode).toBe("dark");
|
||||||
|
|
||||||
|
// Verify the project config doesn't contain themeMode
|
||||||
|
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
|
||||||
|
const config = JSON.parse(configRaw);
|
||||||
|
expect(config.settings.themeMode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateGlobalSettings persists global fields", async () => {
|
||||||
|
await store.updateGlobalSettings({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.defaultProvider).toBe("openai");
|
||||||
|
expect(settings.defaultModelId).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateGlobalSettings emits settings:updated event", async () => {
|
||||||
|
const events: Array<{ settings: any; previous: any }> = [];
|
||||||
|
store.on("settings:updated", (data) => events.push(data));
|
||||||
|
|
||||||
|
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test" });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].settings.ntfyEnabled).toBe(true);
|
||||||
|
expect(events[0].settings.ntfyTopic).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSettingsByScope returns separated global and project settings", async () => {
|
||||||
|
await store.updateGlobalSettings({ themeMode: "system", defaultProvider: "anthropic" });
|
||||||
|
await store.updateSettings({ maxConcurrent: 4, autoMerge: false });
|
||||||
|
|
||||||
|
const { global, project } = await store.getSettingsByScope();
|
||||||
|
|
||||||
|
expect(global.themeMode).toBe("system");
|
||||||
|
expect(global.defaultProvider).toBe("anthropic");
|
||||||
|
expect(project.maxConcurrent).toBe(4);
|
||||||
|
expect(project.autoMerge).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSettingsByScope does not include global keys in project settings", async () => {
|
||||||
|
// Write global-key directly into config.json for backward compat testing
|
||||||
|
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
|
||||||
|
const config = JSON.parse(configRaw);
|
||||||
|
config.settings = { maxConcurrent: 3, themeMode: "light" };
|
||||||
|
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
|
||||||
|
|
||||||
|
const { project } = await store.getSettingsByScope();
|
||||||
|
|
||||||
|
expect(project.maxConcurrent).toBe(3);
|
||||||
|
// themeMode is a global key — should not appear in project scope
|
||||||
|
expect((project as any).themeMode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backward compat: existing projects with global fields in config.json still work", async () => {
|
||||||
|
// Simulate an old config that has both global and project fields
|
||||||
|
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
|
||||||
|
const config = JSON.parse(configRaw);
|
||||||
|
config.settings = { maxConcurrent: 6, themeMode: "system", ntfyEnabled: true };
|
||||||
|
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
|
||||||
|
|
||||||
|
// getSettings should still see these values (project overrides global)
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.maxConcurrent).toBe(6);
|
||||||
|
// These are global fields stored in old config — they show up via config.settings spread
|
||||||
|
expect(settings.themeMode).toBe("system");
|
||||||
|
expect(settings.ntfyEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getGlobalSettingsStore returns the store instance", () => {
|
||||||
|
const globalStore = store.getGlobalSettingsStore();
|
||||||
|
expect(globalStore).toBeDefined();
|
||||||
|
expect(globalStore.getSettingsPath()).toContain("settings.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Concurrent stress test ───────────────────────────────────────
|
// ── Concurrent stress test ───────────────────────────────────────
|
||||||
|
|
||||||
describe("concurrent stress", () => {
|
describe("concurrent stress", () => {
|
||||||
@@ -2773,7 +2881,7 @@ describe("TaskStore", () => {
|
|||||||
await store.cleanupArchivedTasks();
|
await store.cleanupArchivedTasks();
|
||||||
|
|
||||||
// Create new store instance
|
// Create new store instance
|
||||||
const newStore = new TaskStore(rootDir);
|
const newStore = new TaskStore(rootDir, globalDir);
|
||||||
await newStore.init();
|
await newStore.init();
|
||||||
|
|
||||||
const entries = await newStore.readArchiveLog();
|
const entries = await newStore.readArchiveLog();
|
||||||
@@ -2866,7 +2974,7 @@ describe("TaskStore", () => {
|
|||||||
await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" });
|
await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" });
|
||||||
|
|
||||||
// Create new store instance
|
// Create new store instance
|
||||||
const newStore = new TaskStore(rootDir);
|
const newStore = new TaskStore(rootDir, globalDir);
|
||||||
await newStore.init();
|
await newStore.init();
|
||||||
|
|
||||||
const logs = await newStore.getActivityLog();
|
const logs = await newStore.getActivityLog();
|
||||||
@@ -2915,7 +3023,8 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("records activity on settings:updated for important changes", async () => {
|
it("records activity on settings:updated for important changes", async () => {
|
||||||
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
// ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
|
||||||
|
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
// Wait for async activity recording
|
// Wait for async activity recording
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
const logs = await store.getActivityLog({ type: "settings:updated" });
|
const logs = await store.getActivityLog({ type: "settings:updated" });
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { execSync } from "node:child_process";
|
|||||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||||
import { join, sep } from "node:path";
|
import { join, sep } from "node:path";
|
||||||
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
|
||||||
|
import { GlobalSettingsStore } from "./global-settings.js";
|
||||||
|
|
||||||
export interface TaskStoreEvents {
|
export interface TaskStoreEvents {
|
||||||
"task:created": [task: Task];
|
"task:created": [task: Task];
|
||||||
@@ -37,8 +38,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private taskLocks: Map<string, Promise<void>> = new Map();
|
private taskLocks: Map<string, Promise<void>> = new Map();
|
||||||
/** Promise chain for serializing config.json read-modify-write cycles */
|
/** Promise chain for serializing config.json read-modify-write cycles */
|
||||||
private configLock: Promise<void> = Promise.resolve();
|
private configLock: Promise<void> = Promise.resolve();
|
||||||
|
/** Global settings store (`~/.pi/kb/settings.json`) */
|
||||||
|
private globalSettingsStore: GlobalSettingsStore;
|
||||||
|
|
||||||
constructor(private rootDir: string) {
|
constructor(private rootDir: string, globalSettingsDir?: string) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(100);
|
this.setMaxListeners(100);
|
||||||
this.kbDir = join(rootDir, ".kb");
|
this.kbDir = join(rootDir, ".kb");
|
||||||
@@ -46,6 +49,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
this.configPath = join(this.kbDir, "config.json");
|
this.configPath = join(this.kbDir, "config.json");
|
||||||
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
|
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
|
||||||
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
|
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
|
||||||
|
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
@@ -232,23 +236,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await rename(tmpPath, taskJsonPath);
|
await rename(tmpPath, taskJsonPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get merged settings: global defaults ← global user prefs ← project overrides.
|
||||||
|
*
|
||||||
|
* Returns the combined view that most consumers should use. Project-level
|
||||||
|
* values in `.kb/config.json` override global values from `~/.pi/kb/settings.json`.
|
||||||
|
*/
|
||||||
async getSettings(): Promise<Settings> {
|
async getSettings(): Promise<Settings> {
|
||||||
const config = await this.readConfig();
|
const [globalSettings, config] = await Promise.all([
|
||||||
return { ...DEFAULT_SETTINGS, ...config.settings };
|
this.globalSettingsStore.getSettings(),
|
||||||
|
this.readConfig(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...globalSettings,
|
||||||
|
...config.settings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings separated by scope. Returns both the global and
|
||||||
|
* project-level settings independently (useful for the UI to show
|
||||||
|
* which scope a value comes from).
|
||||||
|
*/
|
||||||
|
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||||
|
const [globalSettings, config] = await Promise.all([
|
||||||
|
this.globalSettingsStore.getSettings(),
|
||||||
|
this.readConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract only project-level keys from config.settings
|
||||||
|
const projectSettings: Partial<ProjectSettings> = {};
|
||||||
|
if (config.settings) {
|
||||||
|
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||||
|
for (const key of Object.keys(config.settings)) {
|
||||||
|
if (!globalKeySet.has(key)) {
|
||||||
|
(projectSettings as any)[key] = (config.settings as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { global: globalSettings, project: projectSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project-level settings in `.kb/config.json`.
|
||||||
|
*
|
||||||
|
* Accepts `Partial<Settings>` for backward compatibility. Any global-only
|
||||||
|
* fields in the patch are silently filtered out — they will not be persisted
|
||||||
|
* to the project config. Use `updateGlobalSettings()` for global fields.
|
||||||
|
*/
|
||||||
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
||||||
|
// Filter out global-only fields — they should go through updateGlobalSettings()
|
||||||
|
const projectPatch: Partial<Settings> = {};
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (!(GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key)) {
|
||||||
|
(projectPatch as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.withConfigLock(async () => {
|
return this.withConfigLock(async () => {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const previous = { ...DEFAULT_SETTINGS, ...config.settings };
|
const globalSettings = await this.globalSettingsStore.getSettings();
|
||||||
const updated = { ...previous, ...patch };
|
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
|
||||||
config.settings = updated;
|
const updatedProjectSettings = { ...config.settings, ...projectPatch };
|
||||||
|
config.settings = updatedProjectSettings as Settings;
|
||||||
await this.writeConfig(config);
|
await this.writeConfig(config);
|
||||||
this.emit("settings:updated", { settings: updated, previous });
|
const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings;
|
||||||
return updated;
|
this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged });
|
||||||
|
return updatedMerged;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update global (user-level) settings in `~/.pi/kb/settings.json`.
|
||||||
|
*
|
||||||
|
* These settings persist across all kb projects for the current user.
|
||||||
|
* Only fields defined in `GlobalSettings` are accepted.
|
||||||
|
*/
|
||||||
|
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> {
|
||||||
|
// Read previous state BEFORE writing so the diff is correct
|
||||||
|
const [previousGlobal, config] = await Promise.all([
|
||||||
|
this.globalSettingsStore.getSettings(),
|
||||||
|
this.readConfig(),
|
||||||
|
]);
|
||||||
|
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
|
||||||
|
|
||||||
|
const updatedGlobal = await this.globalSettingsStore.updateSettings(patch);
|
||||||
|
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
||||||
|
|
||||||
|
// Emit settings:updated so SSE listeners pick up the change
|
||||||
|
this.emit("settings:updated", { settings: merged, previous });
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GlobalSettingsStore instance (used by API routes).
|
||||||
|
*/
|
||||||
|
getGlobalSettingsStore(): GlobalSettingsStore {
|
||||||
|
return this.globalSettingsStore;
|
||||||
|
}
|
||||||
|
|
||||||
private async readConfig(): Promise<BoardConfig> {
|
private async readConfig(): Promise<BoardConfig> {
|
||||||
const data = await readFile(this.configPath, "utf-8");
|
const data = await readFile(this.configPath, "utf-8");
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
@@ -1917,7 +2005,11 @@ ${notificationsSection}`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous version of getSettings for internal use.
|
* Synchronous version of getSettings for internal use.
|
||||||
* Returns cached settings or default settings if not loaded.
|
* Returns project-level settings merged with defaults.
|
||||||
|
* Note: This does NOT merge global settings because it's synchronous
|
||||||
|
* and global settings require async I/O. For prompt generation this
|
||||||
|
* is fine since the fields used (ntfyEnabled, ntfyTopic) will be
|
||||||
|
* present in project config for backward compatibility.
|
||||||
*/
|
*/
|
||||||
private getSettingsSync(): Settings {
|
private getSettingsSync(): Settings {
|
||||||
// Since we can't easily make generateSpecifiedPrompt async,
|
// Since we can't easily make generateSpecifiedPrompt async,
|
||||||
|
|||||||
@@ -236,7 +236,69 @@ export interface TaskCreateInput {
|
|||||||
validatorModelId?: string;
|
validatorModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
// ── Settings Scope Types ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Settings are split into two scopes:
|
||||||
|
//
|
||||||
|
// 1. **GlobalSettings** — User preferences stored in `~/.pi/kb/settings.json`.
|
||||||
|
// These persist across all kb projects for the current user (theme, default
|
||||||
|
// AI models, notification preferences).
|
||||||
|
//
|
||||||
|
// 2. **ProjectSettings** — Project-specific workflow and resource settings stored
|
||||||
|
// in `.kb/config.json`. These control how the engine operates for this
|
||||||
|
// particular project (concurrency, merge strategy, worktree management, etc.).
|
||||||
|
//
|
||||||
|
// The merged view (`Settings`) combines both scopes: project values override
|
||||||
|
// global values. This is the type returned by `TaskStore.getSettings()` and
|
||||||
|
// used by most consumers.
|
||||||
|
//
|
||||||
|
// Computed/server-only fields (like `githubTokenConfigured`) live only on
|
||||||
|
// `Settings` and are injected at read time by the API layer.
|
||||||
|
|
||||||
|
/** Settings scope discriminator for UI and validation. */
|
||||||
|
export type SettingsScope = "global" | "project";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global (user-level) settings stored in `~/.pi/kb/settings.json`.
|
||||||
|
*
|
||||||
|
* These are user preferences that persist across all kb projects.
|
||||||
|
* The dashboard UI shows these under a "Global" section.
|
||||||
|
*/
|
||||||
|
export interface GlobalSettings {
|
||||||
|
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
|
||||||
|
themeMode?: ThemeMode;
|
||||||
|
/** Color theme preference for accent colors and styling. Default: "default". */
|
||||||
|
colorTheme?: ColorTheme;
|
||||||
|
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
||||||
|
* Must be set together with `defaultModelId`. When both are undefined,
|
||||||
|
* the engine uses pi's automatic model resolution. */
|
||||||
|
defaultProvider?: string;
|
||||||
|
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
|
||||||
|
* Must be set together with `defaultProvider`. When both are undefined,
|
||||||
|
* the engine uses pi's automatic model resolution. */
|
||||||
|
defaultModelId?: string;
|
||||||
|
/** Default thinking effort level for AI agent sessions.
|
||||||
|
* Controls how much reasoning effort the model uses — higher levels
|
||||||
|
* produce better results but cost more. When undefined, the engine
|
||||||
|
* uses the model's default thinking level. */
|
||||||
|
defaultThinkingLevel?: ThinkingLevel;
|
||||||
|
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||||
|
* Requires ntfyTopic to be set. Default: false. */
|
||||||
|
ntfyEnabled?: boolean;
|
||||||
|
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||||
|
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||||
|
ntfyTopic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-level settings stored in `.kb/config.json`.
|
||||||
|
*
|
||||||
|
* These control how the engine operates for this particular project:
|
||||||
|
* concurrency, merge strategy, worktree management, build/test commands, etc.
|
||||||
|
* Runtime state fields (globalPause, enginePaused) also live here because
|
||||||
|
* different projects may need independent pause control.
|
||||||
|
*/
|
||||||
|
export interface ProjectSettings {
|
||||||
/** Hard stop: when true, all automated agent activity is **immediately**
|
/** Hard stop: when true, all automated agent activity is **immediately**
|
||||||
* terminated — active triage, execution, and merge agent sessions are
|
* terminated — active triage, execution, and merge agent sessions are
|
||||||
* killed, and the scheduler stops dispatching new work. Acts as a
|
* killed, and the scheduler stops dispatching new work. Acts as a
|
||||||
@@ -288,21 +350,10 @@ export interface Settings {
|
|||||||
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||||
* their original IDs. */
|
* their original IDs. */
|
||||||
taskPrefix?: string;
|
taskPrefix?: string;
|
||||||
/** Whether GitHub token is configured for PR operations (read-only, set by server).
|
|
||||||
* When false, PR creation features are disabled in the UI. */
|
|
||||||
githubTokenConfigured?: boolean;
|
|
||||||
/** When true, merge commit messages include the task ID as the conventional
|
/** When true, merge commit messages include the task ID as the conventional
|
||||||
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
|
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
|
||||||
* omitted (e.g. `feat: ...`). Default: true. */
|
* omitted (e.g. `feat: ...`). Default: true. */
|
||||||
includeTaskIdInCommit?: boolean;
|
includeTaskIdInCommit?: boolean;
|
||||||
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
|
||||||
* Must be set together with `defaultModelId`. When both are undefined,
|
|
||||||
* the engine uses pi's automatic model resolution. */
|
|
||||||
defaultProvider?: string;
|
|
||||||
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
|
|
||||||
* Must be set together with `defaultProvider`. When both are undefined,
|
|
||||||
* the engine uses pi's automatic model resolution. */
|
|
||||||
defaultModelId?: string;
|
|
||||||
/** AI model provider for planning/triage (specification) agent.
|
/** AI model provider for planning/triage (specification) agent.
|
||||||
* Must be set together with `planningModelId`. When both are undefined,
|
* Must be set together with `planningModelId`. When both are undefined,
|
||||||
* falls back to `defaultProvider`/`defaultModelId`. */
|
* falls back to `defaultProvider`/`defaultModelId`. */
|
||||||
@@ -325,11 +376,6 @@ export interface Settings {
|
|||||||
autoSelectModelPreset?: boolean;
|
autoSelectModelPreset?: boolean;
|
||||||
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
|
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
|
||||||
defaultPresetBySize?: { S?: string; M?: string; L?: string };
|
defaultPresetBySize?: { S?: string; M?: string; L?: string };
|
||||||
/** Default thinking effort level for AI agent sessions.
|
|
||||||
* Controls how much reasoning effort the model uses — higher levels
|
|
||||||
* produce better results but cost more. When undefined, the engine
|
|
||||||
* uses the model's default thinking level. */
|
|
||||||
defaultThinkingLevel?: ThinkingLevel;
|
|
||||||
/** When true, auto-merge will automatically resolve common conflict patterns
|
/** When true, auto-merge will automatically resolve common conflict patterns
|
||||||
* (lock files, generated files, trivial conflicts) without requiring AI
|
* (lock files, generated files, trivial conflicts) without requiring AI
|
||||||
* intervention. When AI resolution fails, the system will retry with escalating
|
* intervention. When AI resolution fails, the system will retry with escalating
|
||||||
@@ -344,24 +390,41 @@ export interface Settings {
|
|||||||
* remain in triage with status "awaiting-approval" until a user approves
|
* remain in triage with status "awaiting-approval" until a user approves
|
||||||
* or rejects the plan. Default: false. */
|
* or rejects the plan. Default: false. */
|
||||||
requirePlanApproval?: boolean;
|
requirePlanApproval?: boolean;
|
||||||
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
|
||||||
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
|
||||||
ntfyTopic?: string;
|
|
||||||
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
|
||||||
* Requires ntfyTopic to be set. Default: false. */
|
|
||||||
ntfyEnabled?: boolean;
|
|
||||||
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
|
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
|
||||||
* shows no activity (no text deltas, tool calls, or progress updates) for longer
|
* shows no activity (no text deltas, tool calls, or progress updates) for longer
|
||||||
* than this duration, the task is considered stuck and will be terminated and retried.
|
* than this duration, the task is considered stuck and will be terminated and retried.
|
||||||
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
|
|
||||||
themeMode?: ThemeMode;
|
|
||||||
/** Color theme preference for accent colors and styling. Default: "default". */
|
|
||||||
colorTheme?: ColorTheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
/**
|
||||||
|
* Merged settings view combining global and project scopes.
|
||||||
|
*
|
||||||
|
* This is the primary type returned by `TaskStore.getSettings()` and used
|
||||||
|
* by most consumers. Project settings override global settings.
|
||||||
|
*
|
||||||
|
* Also includes computed/server-only fields like `githubTokenConfigured`
|
||||||
|
* that are injected at read time by the API layer.
|
||||||
|
*/
|
||||||
|
export interface Settings extends GlobalSettings, ProjectSettings {
|
||||||
|
/** Whether GitHub token is configured for PR operations (read-only, set by server).
|
||||||
|
* When false, PR creation features are disabled in the UI. */
|
||||||
|
githubTokenConfigured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default values for global (user-level) settings. */
|
||||||
|
export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode" | "colorTheme">> & GlobalSettings = {
|
||||||
|
themeMode: "dark",
|
||||||
|
colorTheme: "default",
|
||||||
|
defaultProvider: undefined,
|
||||||
|
defaultModelId: undefined,
|
||||||
|
defaultThinkingLevel: undefined,
|
||||||
|
ntfyEnabled: false,
|
||||||
|
ntfyTopic: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default values for project-level settings. */
|
||||||
|
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||||
globalPause: false,
|
globalPause: false,
|
||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
@@ -375,8 +438,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
worktreeNaming: "random",
|
worktreeNaming: "random",
|
||||||
taskPrefix: undefined,
|
taskPrefix: undefined,
|
||||||
includeTaskIdInCommit: true,
|
includeTaskIdInCommit: true,
|
||||||
defaultProvider: undefined,
|
|
||||||
defaultModelId: undefined,
|
|
||||||
planningProvider: undefined,
|
planningProvider: undefined,
|
||||||
planningModelId: undefined,
|
planningModelId: undefined,
|
||||||
validatorProvider: undefined,
|
validatorProvider: undefined,
|
||||||
@@ -384,17 +445,63 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
modelPresets: [],
|
modelPresets: [],
|
||||||
autoSelectModelPreset: false,
|
autoSelectModelPreset: false,
|
||||||
defaultPresetBySize: {},
|
defaultPresetBySize: {},
|
||||||
defaultThinkingLevel: undefined,
|
|
||||||
autoResolveConflicts: true,
|
autoResolveConflicts: true,
|
||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
ntfyEnabled: false,
|
|
||||||
ntfyTopic: undefined,
|
|
||||||
taskStuckTimeoutMs: undefined,
|
taskStuckTimeoutMs: undefined,
|
||||||
themeMode: "dark",
|
|
||||||
colorTheme: "default",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merged default settings (backward compatible).
|
||||||
|
* This combines global and project defaults into a single object
|
||||||
|
* that matches the legacy `DEFAULT_SETTINGS` shape.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
...DEFAULT_GLOBAL_SETTINGS,
|
||||||
|
...DEFAULT_PROJECT_SETTINGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keys that belong to the global settings scope. */
|
||||||
|
export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||||
|
"themeMode",
|
||||||
|
"colorTheme",
|
||||||
|
"defaultProvider",
|
||||||
|
"defaultModelId",
|
||||||
|
"defaultThinkingLevel",
|
||||||
|
"ntfyEnabled",
|
||||||
|
"ntfyTopic",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Keys that belong to the project settings scope. */
|
||||||
|
export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||||
|
"globalPause",
|
||||||
|
"enginePaused",
|
||||||
|
"maxConcurrent",
|
||||||
|
"maxWorktrees",
|
||||||
|
"pollIntervalMs",
|
||||||
|
"groupOverlappingFiles",
|
||||||
|
"autoMerge",
|
||||||
|
"mergeStrategy",
|
||||||
|
"worktreeInitCommand",
|
||||||
|
"testCommand",
|
||||||
|
"buildCommand",
|
||||||
|
"recycleWorktrees",
|
||||||
|
"worktreeNaming",
|
||||||
|
"taskPrefix",
|
||||||
|
"includeTaskIdInCommit",
|
||||||
|
"planningProvider",
|
||||||
|
"planningModelId",
|
||||||
|
"validatorProvider",
|
||||||
|
"validatorModelId",
|
||||||
|
"modelPresets",
|
||||||
|
"autoSelectModelPreset",
|
||||||
|
"defaultPresetBySize",
|
||||||
|
"autoResolveConflicts",
|
||||||
|
"smartConflictResolution",
|
||||||
|
"requirePlanApproval",
|
||||||
|
"taskStuckTimeoutMs",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
nextId: number;
|
nextId: number;
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
|
|||||||
@@ -394,8 +394,11 @@ When `KB_BADGE_PUBSUB_REDIS_URL` is not set, the dashboard uses an in-memory ada
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- `GET /api/config` - Server configuration
|
- `GET /api/config` - Server configuration
|
||||||
- `GET /api/settings` - User settings
|
- `GET /api/settings` - Merged settings (project overrides global)
|
||||||
- `PUT /api/settings` - Update settings
|
- `PUT /api/settings` - Update project-level settings (rejects global-only fields)
|
||||||
|
- `GET /api/settings/global` - Global user settings (~/.pi/kb/settings.json)
|
||||||
|
- `PUT /api/settings/global` - Update global user settings
|
||||||
|
- `GET /api/settings/scopes` - Settings separated by scope: { global, project }
|
||||||
- `GET /api/models` - Available AI models
|
- `GET /api/models` - Available AI models
|
||||||
- `GET /api/auth/status` - OAuth provider status
|
- `GET /api/auth/status` - OAuth provider status
|
||||||
- `POST /api/auth/login` - Initiate OAuth login
|
- `POST /api/auth/login` - Initiate OAuth login
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
Column,
|
Column,
|
||||||
MergeResult,
|
MergeResult,
|
||||||
Settings,
|
Settings,
|
||||||
|
GlobalSettings,
|
||||||
|
ProjectSettings,
|
||||||
BatchStatusResult,
|
BatchStatusResult,
|
||||||
BatchStatusResponse,
|
BatchStatusResponse,
|
||||||
BatchStatusEntry,
|
BatchStatusEntry,
|
||||||
@@ -198,6 +200,24 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch global (user-level) settings from ~/.pi/kb/settings.json */
|
||||||
|
export function fetchGlobalSettings(): Promise<GlobalSettings> {
|
||||||
|
return api<GlobalSettings>("/settings/global");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update global (user-level) settings. These persist across all kb projects. */
|
||||||
|
export function updateGlobalSettings(settings: Partial<GlobalSettings>): Promise<Settings> {
|
||||||
|
return api<Settings>("/settings/global", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch settings separated by scope: { global, project } */
|
||||||
|
export function fetchSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||||
|
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>("/settings/scopes");
|
||||||
|
}
|
||||||
|
|
||||||
export function testNtfyNotification(): Promise<{ success: boolean }> {
|
export function testNtfyNotification(): Promise<{ success: boolean }> {
|
||||||
return api<{ success: boolean }>("/settings/test-ntfy", {
|
return api<{ success: boolean }>("/settings/test-ntfy", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { THINKING_LEVELS } from "@kb/core";
|
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@kb/core";
|
||||||
import type { Settings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core";
|
||||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
||||||
import type { AuthProvider, ModelInfo } from "../api";
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ThemeSelector } from "./ThemeSelector";
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
@@ -12,32 +12,38 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
|
|||||||
* Settings sections configuration.
|
* Settings sections configuration.
|
||||||
*
|
*
|
||||||
* Each section groups related settings fields under a sidebar nav item.
|
* 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)
|
||||||
|
* - "project": Project-specific settings stored in .kb/config.json
|
||||||
|
* - undefined: Section operates independently of settings storage (e.g. authentication)
|
||||||
|
*
|
||||||
* To add a new section:
|
* To add a new section:
|
||||||
* 1. Add an entry to SETTINGS_SECTIONS with a unique id and label
|
* 1. Add an entry to SETTINGS_SECTIONS with a unique id, label, and scope
|
||||||
* 2. Add a corresponding case in renderSectionFields()
|
* 2. Add a corresponding case in renderSectionFields()
|
||||||
*
|
*
|
||||||
* Sections:
|
* Sections:
|
||||||
* - general: Task prefix configuration
|
* - general: Task prefix configuration (project)
|
||||||
* - model: Default AI model selection
|
* - model: Default AI model selection (global)
|
||||||
* - appearance: Theme and color settings
|
* - model-presets: Reusable model presets (project)
|
||||||
* - scheduling: Concurrency, poll interval, file overlap serialization
|
* - appearance: Theme and color settings (global)
|
||||||
* - worktrees: Worktree limits, init commands, recycling
|
* - scheduling: Concurrency, poll interval, file overlap serialization (project)
|
||||||
* - commands: Test and build command configuration
|
* - worktrees: Worktree limits, init commands, recycling (project)
|
||||||
* - merge: Auto-merge settings
|
* - commands: Test and build command configuration (project)
|
||||||
* - notifications: ntfy.sh notification settings
|
* - merge: Auto-merge settings (project)
|
||||||
* - authentication: OAuth provider status, login/logout (operates independently of Save)
|
* - notifications: ntfy.sh notification settings (global)
|
||||||
|
* - authentication: OAuth provider status, login/logout (independent)
|
||||||
*/
|
*/
|
||||||
const SETTINGS_SECTIONS = [
|
const SETTINGS_SECTIONS = [
|
||||||
{ id: "general", label: "General" },
|
{ id: "general", label: "General", scope: "project" as const },
|
||||||
{ id: "model", label: "Model" },
|
{ id: "model", label: "Model", scope: "global" as const },
|
||||||
{ id: "model-presets", label: "Model Presets" },
|
{ id: "model-presets", label: "Model Presets", scope: "project" as const },
|
||||||
{ id: "appearance", label: "Appearance" },
|
{ id: "appearance", label: "Appearance", scope: "global" as const },
|
||||||
{ id: "scheduling", label: "Scheduling" },
|
{ id: "scheduling", label: "Scheduling", scope: "project" as const },
|
||||||
{ id: "worktrees", label: "Worktrees" },
|
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
|
||||||
{ id: "commands", label: "Commands" },
|
{ id: "commands", label: "Commands", scope: "project" as const },
|
||||||
{ id: "merge", label: "Merge" },
|
{ id: "merge", label: "Merge", scope: "project" as const },
|
||||||
{ id: "notifications", label: "Notifications" },
|
{ id: "notifications", label: "Notifications", scope: "global" as const },
|
||||||
{ id: "authentication", label: "Authentication" },
|
{ id: "authentication", label: "Authentication", scope: undefined },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
|
export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
|
||||||
@@ -212,6 +218,9 @@ export function SettingsModal({
|
|||||||
[onClose],
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Get the scope of the currently active section */
|
||||||
|
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (prefixError || presetDraft) return;
|
if (prefixError || presetDraft) return;
|
||||||
try {
|
try {
|
||||||
@@ -220,13 +229,38 @@ export function SettingsModal({
|
|||||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||||
};
|
};
|
||||||
await updateSettings(payload);
|
|
||||||
|
// Save only the scope matching the currently active section.
|
||||||
|
// This prevents stale values from one scope being accidentally
|
||||||
|
// overwritten when the user only changed fields in the other scope.
|
||||||
|
if (activeSectionScope === "global") {
|
||||||
|
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||||
|
const globalPatch: Partial<GlobalSettings> = {};
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (globalKeySet.has(key)) {
|
||||||
|
(globalPatch as any)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateGlobalSettings(globalPatch);
|
||||||
|
} else if (activeSectionScope === "project") {
|
||||||
|
const projectKeySet = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||||
|
const projectPatch: Partial<Settings> = {};
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (key === "githubTokenConfigured") continue; // server-only field
|
||||||
|
if (projectKeySet.has(key)) {
|
||||||
|
(projectPatch as any)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateSettings(projectPatch);
|
||||||
|
}
|
||||||
|
// Authentication section (scope: undefined) doesn't use the save button
|
||||||
|
|
||||||
addToast("Settings saved", "success");
|
addToast("Settings saved", "success");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addToast(err.message, "error");
|
addToast(err.message, "error");
|
||||||
}
|
}
|
||||||
}, [form, prefixError, onClose, addToast]);
|
}, [form, prefixError, presetDraft, activeSectionScope, onClose, addToast]);
|
||||||
|
|
||||||
const savePresetDraft = () => {
|
const savePresetDraft = () => {
|
||||||
if (!presetDraft) return;
|
if (!presetDraft) return;
|
||||||
@@ -266,11 +300,33 @@ export function SettingsModal({
|
|||||||
setPresetIdTouched(false);
|
setPresetIdTouched(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Render a scope indicator banner for the current section */
|
||||||
|
const renderScopeBanner = () => {
|
||||||
|
if (activeSectionScope === "global") {
|
||||||
|
return (
|
||||||
|
<div className="settings-scope-banner settings-scope-global">
|
||||||
|
<span>🌐</span>
|
||||||
|
<span>These settings are shared across all your kb projects.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activeSectionScope === "project") {
|
||||||
|
return (
|
||||||
|
<div className="settings-scope-banner settings-scope-project">
|
||||||
|
<span>📁</span>
|
||||||
|
<span>These settings only affect this project.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const renderSectionFields = () => {
|
const renderSectionFields = () => {
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "general":
|
case "general":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">General</h4>
|
<h4 className="settings-section-heading">General</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="taskPrefix">Task Prefix</label>
|
<label htmlFor="taskPrefix">Task Prefix</label>
|
||||||
@@ -320,6 +376,7 @@ export function SettingsModal({
|
|||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Model</h4>
|
<h4 className="settings-section-heading">Model</h4>
|
||||||
{modelsLoading ? (
|
{modelsLoading ? (
|
||||||
<div className="settings-empty-state">Loading available models…</div>
|
<div className="settings-empty-state">Loading available models…</div>
|
||||||
@@ -437,6 +494,7 @@ export function SettingsModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Model Presets</h4>
|
<h4 className="settings-section-heading">Model Presets</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Configured presets</label>
|
<label>Configured presets</label>
|
||||||
@@ -650,18 +708,26 @@ export function SettingsModal({
|
|||||||
case "appearance":
|
case "appearance":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Appearance</h4>
|
<h4 className="settings-section-heading">Appearance</h4>
|
||||||
<ThemeSelector
|
<ThemeSelector
|
||||||
themeMode={themeMode}
|
themeMode={themeMode}
|
||||||
colorTheme={colorTheme}
|
colorTheme={colorTheme}
|
||||||
onThemeModeChange={onThemeModeChange || (() => {})}
|
onThemeModeChange={(mode) => {
|
||||||
onColorThemeChange={onColorThemeChange || (() => {})}
|
setForm((f) => ({ ...f, themeMode: mode }));
|
||||||
|
onThemeModeChange?.(mode);
|
||||||
|
}}
|
||||||
|
onColorThemeChange={(theme) => {
|
||||||
|
setForm((f) => ({ ...f, colorTheme: theme }));
|
||||||
|
onColorThemeChange?.(theme);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "scheduling":
|
case "scheduling":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Scheduling</h4>
|
<h4 className="settings-section-heading">Scheduling</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
||||||
@@ -724,6 +790,7 @@ export function SettingsModal({
|
|||||||
case "worktrees":
|
case "worktrees":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Worktrees</h4>
|
<h4 className="settings-section-heading">Worktrees</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="maxWorktrees">Max Worktrees</label>
|
<label htmlFor="maxWorktrees">Max Worktrees</label>
|
||||||
@@ -791,6 +858,7 @@ export function SettingsModal({
|
|||||||
case "commands":
|
case "commands":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Commands</h4>
|
<h4 className="settings-section-heading">Commands</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="testCommand">Test Command</label>
|
<label htmlFor="testCommand">Test Command</label>
|
||||||
@@ -823,6 +891,7 @@ export function SettingsModal({
|
|||||||
case "merge":
|
case "merge":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Merge</h4>
|
<h4 className="settings-section-heading">Merge</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="autoMerge" className="checkbox-label">
|
<label htmlFor="autoMerge" className="checkbox-label">
|
||||||
@@ -901,6 +970,7 @@ export function SettingsModal({
|
|||||||
case "notifications":
|
case "notifications":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Notifications</h4>
|
<h4 className="settings-section-heading">Notifications</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
||||||
@@ -1036,7 +1106,10 @@ export function SettingsModal({
|
|||||||
key={section.id}
|
key={section.id}
|
||||||
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
|
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
|
||||||
onClick={() => setActiveSection(section.id)}
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
title={section.scope === "global" ? "Shared across all projects" : section.scope === "project" ? "Specific to this project" : undefined}
|
||||||
>
|
>
|
||||||
|
{section.scope === "global" && <span className="settings-scope-icon" aria-label="Global setting">🌐</span>}
|
||||||
|
{section.scope === "project" && <span className="settings-scope-icon" aria-label="Project setting">📁</span>}
|
||||||
{section.label}
|
{section.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const defaultSettings: Settings = {
|
|||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
|
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
@@ -38,7 +39,7 @@ vi.mock("../../api", () => ({
|
|||||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
|
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
|
||||||
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
@@ -369,6 +370,36 @@ describe("SettingsModal", () => {
|
|||||||
expect(payload.pollIntervalMs).toBe(15000);
|
expect(payload.pollIntervalMs).toBe(15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saving project settings does not update global settings endpoint", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// General section is project-scoped — change a project setting
|
||||||
|
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "TEST" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// Global settings should NOT be called when in a project section
|
||||||
|
expect(updateGlobalSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving in Model section only updates global settings", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Model"));
|
||||||
|
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
// Model section is global-scoped
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// Project settings should NOT be updated when in a global section
|
||||||
|
expect(updateSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows Model in sidebar", async () => {
|
it("shows Model in sidebar", async () => {
|
||||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
@@ -464,9 +495,10 @@ describe("SettingsModal", () => {
|
|||||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// defaultProvider and defaultModelId are global settings, so they go through updateGlobalSettings
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.defaultProvider).toBe("anthropic");
|
expect(payload.defaultProvider).toBe("anthropic");
|
||||||
expect(payload.defaultModelId).toBe("claude-sonnet-4-5");
|
expect(payload.defaultModelId).toBe("claude-sonnet-4-5");
|
||||||
});
|
});
|
||||||
@@ -499,9 +531,10 @@ describe("SettingsModal", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// defaultProvider and defaultModelId are global settings
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.defaultProvider).toBeUndefined();
|
expect(payload.defaultProvider).toBeUndefined();
|
||||||
expect(payload.defaultModelId).toBeUndefined();
|
expect(payload.defaultModelId).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -684,9 +717,10 @@ describe("SettingsModal", () => {
|
|||||||
fireEvent.change(select, { target: { value: "high" } });
|
fireEvent.change(select, { target: { value: "high" } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// defaultThinkingLevel is a global setting
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.defaultThinkingLevel).toBe("high");
|
expect(payload.defaultThinkingLevel).toBe("high");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,17 +833,29 @@ describe("SettingsModal", () => {
|
|||||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => {
|
it("has .settings-sidebar with 10 .settings-nav-item buttons for all sections", async () => {
|
||||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
const sidebar = container.querySelector(".settings-sidebar");
|
const sidebar = container.querySelector(".settings-sidebar");
|
||||||
expect(sidebar).toBeTruthy();
|
expect(sidebar).toBeTruthy();
|
||||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||||
expect(navItems.length).toBe(9);
|
expect(navItems.length).toBe(10);
|
||||||
|
|
||||||
|
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
|
||||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||||
expect(labels).toEqual(["General", "Model", "Appearance", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
expect(labels).toEqual([
|
||||||
|
"📁General",
|
||||||
|
"🌐Model",
|
||||||
|
"📁Model Presets",
|
||||||
|
"🌐Appearance",
|
||||||
|
"📁Scheduling",
|
||||||
|
"📁Worktrees",
|
||||||
|
"📁Commands",
|
||||||
|
"📁Merge",
|
||||||
|
"🌐Notifications",
|
||||||
|
"Authentication",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
||||||
@@ -828,16 +874,16 @@ describe("SettingsModal", () => {
|
|||||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Default active section is General
|
// Default active section is General (with scope emoji prefix)
|
||||||
const activeItems = container.querySelectorAll(".settings-nav-item.active");
|
const activeItems = container.querySelectorAll(".settings-nav-item.active");
|
||||||
expect(activeItems.length).toBe(1);
|
expect(activeItems.length).toBe(1);
|
||||||
expect(activeItems[0].textContent).toBe("General");
|
expect(activeItems[0].textContent).toBe("📁General");
|
||||||
|
|
||||||
// Switch to Scheduling
|
// Switch to Scheduling
|
||||||
fireEvent.click(screen.getByText("Scheduling"));
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
const newActive = container.querySelectorAll(".settings-nav-item.active");
|
const newActive = container.querySelectorAll(".settings-nav-item.active");
|
||||||
expect(newActive.length).toBe(1);
|
expect(newActive.length).toBe(1);
|
||||||
expect(newActive[0].textContent).toBe("Scheduling");
|
expect(newActive[0].textContent).toBe("📁Scheduling");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auth provider rows contain .auth-provider-info and action button", async () => {
|
it("auth provider rows contain .auth-provider-info and action button", async () => {
|
||||||
@@ -910,9 +956,10 @@ describe("SettingsModal", () => {
|
|||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// ntfyEnabled is a global setting, so it goes through updateGlobalSettings
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.ntfyEnabled).toBe(true);
|
expect(payload.ntfyEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -929,9 +976,10 @@ describe("SettingsModal", () => {
|
|||||||
expect(input.value).toBe("my-topic");
|
expect(input.value).toBe("my-topic");
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// ntfyTopic is a global setting
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.ntfyTopic).toBe("my-topic");
|
expect(payload.ntfyTopic).toBe("my-topic");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -950,9 +998,10 @@ describe("SettingsModal", () => {
|
|||||||
fireEvent.change(input, { target: { value: "" } });
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
// ntfyTopic is a global setting
|
||||||
|
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
expect(payload.ntfyTopic).toBeUndefined();
|
expect(payload.ntfyTopic).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1263,4 +1312,18 @@ describe("SettingsModal", () => {
|
|||||||
fireEvent.click(screen.getByText("Scheduling"));
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
expect(screen.getByText(/Timeout in milliseconds for detecting stuck tasks/)).toBeTruthy();
|
expect(screen.getByText(/Timeout in milliseconds for detecting stuck tasks/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scope banners render for global and project sections", async () => {
|
||||||
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// General section is project-scoped → should show project banner
|
||||||
|
expect(container.querySelector(".settings-scope-project")).toBeTruthy();
|
||||||
|
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||||||
|
|
||||||
|
// Switch to Model → should show global banner
|
||||||
|
fireEvent.click(screen.getByText("Model"));
|
||||||
|
expect(container.querySelector(".settings-scope-global")).toBeTruthy();
|
||||||
|
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1170,6 +1170,33 @@ body {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scope indicators in sidebar nav items */
|
||||||
|
.settings-scope-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scope banner above section content */
|
||||||
|
.settings-scope-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.settings-scope-global {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
.settings-scope-project {
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
border-left: 3px solid rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Auth Provider Cards === */
|
/* === Auth Provider Cards === */
|
||||||
.auth-provider-row {
|
.auth-provider-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ import { isGhAuthenticated } from "@kb/core";
|
|||||||
|
|
||||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||||
|
|
||||||
|
function createMockGlobalSettingsStore() {
|
||||||
|
return {
|
||||||
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
|
||||||
|
init: vi.fn().mockResolvedValue(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
return {
|
return {
|
||||||
getTask: vi.fn(),
|
getTask: vi.fn(),
|
||||||
@@ -36,6 +45,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
unarchiveTask: vi.fn(),
|
unarchiveTask: vi.fn(),
|
||||||
getSettings: vi.fn().mockResolvedValue({}),
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
|
updateGlobalSettings: vi.fn(),
|
||||||
|
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||||
|
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
addSteeringComment: vi.fn(),
|
addSteeringComment: vi.fn(),
|
||||||
@@ -4487,6 +4499,50 @@ describe("PUT /settings", () => {
|
|||||||
expect(res.body.error).toContain("must include both provider and modelId or neither");
|
expect(res.body.error).toContain("must include both provider and modelId or neither");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects global-only fields with 400 error and helpful message", async () => {
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"PUT",
|
||||||
|
"/api/settings",
|
||||||
|
JSON.stringify({ themeMode: "dark", maxConcurrent: 4 }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("global settings");
|
||||||
|
expect(res.body.error).toContain("themeMode");
|
||||||
|
expect(res.body.error).toContain("/settings/global");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when only global fields are sent", async () => {
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"PUT",
|
||||||
|
"/api/settings",
|
||||||
|
JSON.stringify({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("defaultProvider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows project-only fields to pass through successfully", async () => {
|
||||||
|
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
|
||||||
|
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"PUT",
|
||||||
|
"/api/settings",
|
||||||
|
JSON.stringify({ maxConcurrent: 8, autoMerge: false }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8, autoMerge: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 500 on store update error", async () => {
|
it("returns 500 on store update error", async () => {
|
||||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
||||||
|
|
||||||
@@ -4502,3 +4558,128 @@ describe("PUT /settings", () => {
|
|||||||
expect(res.body.error).toContain("Write failed");
|
expect(res.body.error).toContain("Write failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /settings/global", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns global settings from the global settings store", async () => {
|
||||||
|
const mockGlobalStore = createMockGlobalSettingsStore();
|
||||||
|
mockGlobalStore.getSettings.mockResolvedValue({ themeMode: "light", colorTheme: "ocean" });
|
||||||
|
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/settings/global");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.themeMode).toBe("light");
|
||||||
|
expect(res.body.colorTheme).toBe("ocean");
|
||||||
|
// Should NOT include server-only fields
|
||||||
|
expect(res.body.githubTokenConfigured).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on global store error", async () => {
|
||||||
|
const mockGlobalStore = createMockGlobalSettingsStore();
|
||||||
|
mockGlobalStore.getSettings.mockRejectedValue(new Error("Read failed"));
|
||||||
|
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/settings/global");
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("Read failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /settings/global", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("updates global settings via store.updateGlobalSettings", async () => {
|
||||||
|
const updatedMerged = { themeMode: "light", maxConcurrent: 2 };
|
||||||
|
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMerged);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"PUT",
|
||||||
|
"/api/settings/global",
|
||||||
|
JSON.stringify({ themeMode: "light" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on update error", async () => {
|
||||||
|
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"PUT",
|
||||||
|
"/api/settings/global",
|
||||||
|
JSON.stringify({ themeMode: "light" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("Write failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /settings/scopes", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns settings separated by scope", async () => {
|
||||||
|
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
global: { themeMode: "dark", defaultProvider: "anthropic" },
|
||||||
|
project: { maxConcurrent: 4, autoMerge: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/settings/scopes");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.global.themeMode).toBe("dark");
|
||||||
|
expect(res.body.global.defaultProvider).toBe("anthropic");
|
||||||
|
expect(res.body.project.maxConcurrent).toBe(4);
|
||||||
|
expect(res.body.project.autoMerge).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 on store error", async () => {
|
||||||
|
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Failed"));
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/settings/scopes");
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("Failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import multer from "multer";
|
|||||||
import { createReadStream, existsSync } from "node:fs";
|
import { createReadStream, existsSync } from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
|
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
|
||||||
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||||
import { githubRateLimiter } from "./github-poll.js";
|
import { githubRateLimiter } from "./github-poll.js";
|
||||||
@@ -932,6 +932,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { githubTokenConfigured, ...clientSettings } = req.body;
|
const { githubTokenConfigured, ...clientSettings } = req.body;
|
||||||
|
|
||||||
|
// Reject global-only fields with a helpful error pointing to the correct endpoint
|
||||||
|
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||||
|
const globalFieldsFound = Object.keys(clientSettings).filter((k) => globalKeySet.has(k));
|
||||||
|
if (globalFieldsFound.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Cannot update global settings via this endpoint. Use PUT /settings/global instead. Global fields found: ${globalFieldsFound.join(", ")}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
|
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
|
||||||
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
||||||
}
|
}
|
||||||
@@ -946,6 +956,51 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Global Settings Routes ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/global
|
||||||
|
* Returns the global (user-level) settings from ~/.pi/kb/settings.json.
|
||||||
|
* Does NOT include computed/server-only fields like githubTokenConfigured.
|
||||||
|
*/
|
||||||
|
router.get("/settings/global", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const globalStore = store.getGlobalSettingsStore();
|
||||||
|
const settings = await globalStore.getSettings();
|
||||||
|
res.json(settings);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/settings/global
|
||||||
|
* Update global (user-level) settings in ~/.pi/kb/settings.json.
|
||||||
|
* These settings persist across all kb projects for the current user.
|
||||||
|
*/
|
||||||
|
router.put("/settings/global", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await store.updateGlobalSettings(req.body);
|
||||||
|
res.json(settings);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/scopes
|
||||||
|
* Returns settings separated by scope: { global, project }.
|
||||||
|
* Useful for the UI to show which scope each setting comes from.
|
||||||
|
*/
|
||||||
|
router.get("/settings/scopes", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const scopes = await store.getSettingsByScope();
|
||||||
|
res.json(scopes);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/settings/test-ntfy
|
* POST /api/settings/test-ntfy
|
||||||
* Send a test notification to verify ntfy configuration.
|
* Send a test notification to verify ntfy configuration.
|
||||||
|
|||||||
Reference in New Issue
Block a user