Repository navigation
feat: global new-chat defaults with per-project overrides (#58) - #62
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #58.
Every new chat opened on literals:
newTab()assignedcurMode='auto'/curAgent='single', the model carried over from whichever tab was open last, and#maxTurnsshippedvalue="50"in the markup. A user who always works on Opus / Task retyped that on every chat.The chain
chat-defaults.jsis pure — nofs, norequire('./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.jsasserts the object written todata/projects.json, not just the resolved answer, because a snapshot satisfies every other assertion in that file.BUILTINis the pre-#58 behaviour verbatim, so an install that never opens the settings form behaves exactly as before.The five asks from the issue
config-resolve.js(section: 'defaults') — the generic form renders them, with source badges and per-row Reset, for freeapplyChatDefaults()fromnewTab(),switchProject()and boot, always behindif (!currentSessionId)PUT /api/projects/:id/defaults+ the ★ button, following the existing "set as default" idiomDELETE /api/projects/:id/defaults+ the ↺ button, shown only when something is actually pinnedOnly 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:loadMergedConfig()spread the two config files RAW and sanitised the result. A localmodel: ""masked a valid globalmodel: "opus", and the merged empty string then fell all the way toBUILTINsonnet — a value neither file ever named — while the settings catalog, which walks the files separately underfalsyFallsThrough, kept reportingopusas effective. Two "effective" answers for one key. Each layer is now sanitised before the spread, which is the||semantics the flag advertises.turnshad two coercion rules.coerceValuestored7.9as7.9and read'12px'as12;chat-defaults.coercetruncated and refused. It gained an opt-inint: truethat parses withNumberand truncates after the range check, matching the resolver exactly. Every pre-existing number row keeps its oldparseIntbehaviour.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 resetDELETEperforms), andGET /api/chat-defaults404s an unknownprojectIdlike 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'sdata-vsets andMODEL_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 throwawayAPP_DIR, with both config layers seeded on disk.test/config-resolve.test.js'sfalsyFallsThroughguard was split rather than weakened: flat keys still have their||/??operator read straight out ofloadMergedConfig(); a key whosepathcontains 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.htmlstill one file.