From 6a1f48e0695e74126b881cf5a895beb1d1351eba Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Wed, 6 May 2026 15:49:29 -0700 Subject: [PATCH] fix(settings): prevent project-level global keys from overriding global settings getSettingsFast() and getSettings() spread project-level settings over global settings without filtering by scope. A stale project-level experimentalFeatures: {} in SQLite would override the global experimentalFeatures: { insights: true, ... }, making all experimental features invisible in the dashboard. Now both methods strip global-only keys from project settings before the merge, matching the behavior of getSettingsByScopeFast(). Also adds max-height and overflow-y to the overflow dropdown menu. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/__tests__/store.test.ts | 19 +++++++++++++++++++ packages/core/src/store.ts | 19 +++++++++++++++++-- packages/dashboard/app/components/Header.css | 2 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index a1f703c2c..a5339096d 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -3062,6 +3062,25 @@ describe("TaskStore", () => { const settings = await store.getSettingsFast(); expect(settings.experimentalFeatures).toEqual({ "fast-feature": true }); }); + + it("project-level experimentalFeatures does not override global value", async () => { + // Set global experimentalFeatures + await store.updateGlobalSettings({ experimentalFeatures: { insights: true, roadmap: true } }); + + // Simulate stale project-level config with empty experimentalFeatures + // (can happen from older clients or direct DB writes) + store.getDatabase() + .prepare("UPDATE config SET settings = ? WHERE id = 1") + .run(JSON.stringify({ experimentalFeatures: {} })); + + // getSettingsFast should ignore the project-level global key + const fastSettings = await store.getSettingsFast(); + expect(fastSettings.experimentalFeatures).toEqual({ insights: true, roadmap: true }); + + // getSettings should also ignore the project-level global key + const settings = await store.getSettings(); + expect(settings.experimentalFeatures).toEqual({ insights: true, roadmap: true }); + }); }); // ── Concurrent stress test ─────────────────────────────────────── diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 6a99bed3f..0f2c10014 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1645,10 +1645,15 @@ export class TaskStore extends EventEmitter { this.globalSettingsStore.getSettings(), this.readConfig(), ]); + // Strip global-only keys from project-level settings so stale project-scoped + // values don't override the correct global value during the spread merge. + const projectSettings = Object.fromEntries( + Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalSettingsKey(key)), + ); return canonicalizeSettings({ ...DEFAULT_SETTINGS, ...globalSettings, - ...config.settings, + ...projectSettings, }); } @@ -1670,7 +1675,17 @@ export class TaskStore extends EventEmitter { this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined, ]); - const projectSettings = row?.settings ? fromJson(row.settings) : undefined; + const raw = row?.settings ? fromJson(row.settings) : undefined; + + // Strip global-only keys from the project-level row so stale project-scoped + // values (e.g. an empty experimentalFeatures={}) don't override the correct + // global value during the spread merge below. getSettingsByScopeFast() has + // always done this; getSettingsFast() was missing the filter. + const projectSettings: Partial | undefined = raw + ? (Object.fromEntries( + Object.entries(raw).filter(([key]) => !isGlobalSettingsKey(key)), + ) as Partial) + : undefined; return canonicalizeSettings({ ...DEFAULT_SETTINGS, diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index d0855340e..db4c38e4d 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -702,6 +702,8 @@ border-radius: var(--radius-md); box-shadow: var(--shadow-lg); min-width: 140px; + max-height: min(400px, 70vh); + overflow-y: auto; z-index: 200; padding: var(--space-xs) 0; }