Skip to content

feat: global new-chat defaults with per-project overrides (#58) - #62

Merged
Lexus2016 merged 2 commits into
mainfrom
feat/58-chat-defaults
Aug 23, 2026
Merged

Lexus2016 merged 2 commits into
mainfrom
feat/58-chat-defaults

Conversation

@Lexus2016

Copy link
Copy Markdown
Owner

Closes #58.

Every new chat opened on literals: newTab() assigned curMode='auto' / curAgent='single', the model carried over from whichever tab was open last, and #maxTurns shipped value="50" in the markup. A user who always works on Opus / Task retyped that on every chat.

The chain

project override  >  global default (config.json)  >  BUILTIN

chat-defaults.js is pure — no fs, no require('./server.js') — so precedence and validation are unit-testable without booting anything.

The project link is deliberately sparse. A project stores only the dials it pinned. A full five-key snapshot would freeze each project at whatever the global happened to be the day it was created, which is the opposite of what the issue asked for. test/chat-defaults-api.test.js asserts the object written to data/projects.json, not just the resolved answer, because a snapshot satisfies every other assertion in that file.

BUILTIN is the pre-#58 behaviour verbatim, so an install that never opens the settings form behaves exactly as before.

The five asks from the issue

Ask How
Global Defaults section in Settings five catalog rows in config-resolve.js (section: 'defaults') — the generic form renders them, with source badges and per-row Reset, for free
Auto-applied to new chats and projects applyChatDefaults() from newTab(), switchProject() and boot, always behind if (!currentSessionId)
Per-project override PUT /api/projects/:id/defaults + the ★ button, following the existing "set as default" idiom
Reset to Defaults at project level DELETE /api/projects/:id/defaults + the ↺ button, shown only when something is actually pinned
Visual override indicator accent border + a dot on the toolbar group label of each pinned dial

Only a chat with no session of its own is seeded — an existing chat carries its mode/model in SQLite and loadSess() keeps winning.

Second commit: what an independent review caught

The diff went through consult --panel codex,grok --review. Both advisors independently found the same precedence bug:

  1. loadMergedConfig() spread the two config files RAW and sanitised the result. A local model: "" masked a valid global model: "opus", and the merged empty string then fell all the way to BUILTIN sonnet — a value neither file ever named — while the settings catalog, which walks the files separately under falsyFallsThrough, kept reporting opus as effective. Two "effective" answers for one key. Each layer is now sanitised before the spread, which is the || semantics the flag advertises.
  2. turns had two coercion rules. coerceValue stored 7.9 as 7.9 and read '12px' as 12; chat-defaults.coerce truncated and refused. It gained an opt-in int: true that parses with Number and truncates after the range check, matching the resolver exactly. Every pre-existing number row keeps its old parseInt behaviour.
  3. sanitize() checked the unpin sentinel before key membership, so {notADial: null} was accepted as an unpin and answered 200. Membership is checked first now. The PUT also refuses a non-object body (a 200 on {defaults: null} reads exactly like the reset DELETE performs), and GET /api/chat-defaults 404s an unknown projectId like PUT/DELETE already did.

One review note was kept as designed: pinProjectDefaults() sends all five dials rather than a diff against the global. The user's mental model is "remember what I see"; a value that merely happens to equal the global today must stay pinned when the global changes tomorrow. ↺ undoes it in one click.

Tests

Two new suites, wired into npm test:

  • test/chat-defaults.test.js — 53 assertions on the pure resolver. The built-ins are asserted to be exactly what the SPA hardcoded before Feature Request: Global Defaults with Per-Project Overrides #58, and the choice lists exactly the toolbar's data-v sets and MODEL_MAP's aliases; a drift there renders as "no button selected".
  • test/chat-defaults-api.test.js — 51 assertions against a real server in a throwaway APP_DIR, with both config layers seeded on disk.

test/config-resolve.test.js's falsyFallsThrough guard was split rather than weakened: flat keys still have their || / ?? operator read straight out of loadMergedConfig(); a key whose path contains a dot has no such line, so it is checked behaviourally by running the resolver.

Full run: 63 files, 1858 assertions, exit 0.

No new dependencies, no build step, public/index.html still one file.

Every new chat opened on literals: newTab() assigned curMode='auto' /
curAgent='single', the model carried over from whichever tab was open last,
and #maxTurns shipped value="50" in the markup. A user who always works on
Opus / Task retyped that on every chat.

chat-defaults.js resolves a two-link chain instead:

    project override  >  global default (config.json)  >  BUILTIN

The project link is deliberately SPARSE — a project stores only the dials it
pinned. A full five-key snapshot would freeze each project at whatever the
global happened to be the day it was created, which is the opposite of what
the issue asked for; it is also why the API test asserts the object written
to data/projects.json and not just the resolved answer.

BUILTIN is the pre-#58 behaviour verbatim, so an install that never opens the
settings form behaves exactly as before.

Global half: five rows in the settings catalog (section 'defaults'), so the
Settings UI gets its section, source badges and per-row Reset for free.
coerceValue() gained optional min/max so the form cannot store a turns value
its own <input> refuses to display.

Project half: PUT/DELETE /api/projects/:id/defaults plus the star/reset pair
in the toolbar, following the existing "set as default" idiom. A PUT touches
only the keys it names; an invalid value is a 400 that names the key rather
than a silently trimmed save. Pinned dials are marked with an accent dot on
the toolbar group label.

Only a chat with no session of its own is seeded — an existing chat carries
its own mode/model in SQLite and loadSess() keeps winning.

Tests: chat-defaults.test.js (46 assertions, pure resolver) and
chat-defaults-api.test.js (42 assertions, real server in a throwaway APP_DIR).
The falsyFallsThrough guard in config-resolve.test.js was split rather than
weakened: flat keys still have their || / ?? operator read out of
loadMergedConfig(), nested ones are checked behaviourally.
Panel review (codex + grok, independently) found the same precedence bug and
two validation holes in the first commit.

1. loadMergedConfig() spread the global and local config objects RAW and let
   resolveChatDefaults() sanitise the result. A local `model: ""` therefore
   masked a valid global `model: "opus"`, and the merged empty string was then
   dropped to BUILTIN sonnet — a value neither file ever named. The settings
   catalog, which walks the two files separately under falsyFallsThrough, kept
   reporting opus as effective: two "effective" answers for one key. Each layer
   is now sanitised before the spread, which is the || semantics the flag
   advertises. Pinned by the new per-layer block in chat-defaults.test.js and by
   a two-file boot in chat-defaults-api.test.js.

2. turns had two coercion rules. config-resolve.coerceValue stored 7.9 as 7.9
   and read '12px' as 12; chat-defaults.coerce truncated and refused. Settings
   and the toolbar could show different limits for one dial. coerceValue gained
   an opt-in `int: true` that parses with Number and truncates after the range
   check, matching the resolver exactly. Every pre-existing number row keeps its
   old parseInt behaviour.

3. sanitize() checked the null/'' unpin sentinel before key membership, so
   {notADial: null} was accepted as an unpin and answered 200. Membership is now
   checked first. The PUT also refuses a non-object body — a 200 on
   {defaults: null} reads exactly like the reset DELETE actually performs — and
   GET /api/chat-defaults 404s an unknown projectId like PUT/DELETE already did,
   instead of rendering a stale project as one that pinned nothing.

Tests: 53 assertions in chat-defaults.test.js, 51 in chat-defaults-api.test.js.
Full npm test: 1858 assertions, exit 0.
@Lexus2016
Lexus2016 merged commit a696098 into main Aug 23, 2026
2 checks passed
@Lexus2016
Lexus2016 deleted the feat/58-chat-defaults branch August 23, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Global Defaults with Per-Project Overrides

1 participant