Skip to content

feat(app): retire the layout toggle and the theme picker - #181

Merged
quickbeard merged 3 commits into
mainfrom
lock-layout-theme
Aug 10, 2026
Merged

quickbeard merged 3 commits into
mainfrom
lock-layout-theme

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Settings → General loses the New layout switch and Settings → Appearance loses the Theme picker. The fork ships one layout and one theme; light/dark stays as the Color scheme axis, and the terminal client keeps its own separate theme picker.

Neither is merely hidden

Layout. The switch came with a whole timed-migration mechanism — a sunset date, a per-profile eligibility flag, a stored newLayoutDesigns preference. Hiding just the switch would have left a stored false (older build, another machine, hand-edited store) still opening the old layout, so the lock is at the source: newLayoutDesigns is now a constant true that never reads the persisted value. setNewLayoutDesigns, layoutTransitionAvailable, newInterfaceNoticeVisible and dismissNewInterfaceNotice are removed from the context rather than stubbed — a no-op setter would let a caller look like it was switching layouts, so re-introducing the toggle is a compile error. The sunset timer, the retirement notice and the effects that wrote the preference back are deleted; interface-transition.tsx goes with its last caller.

Theme. The lock is normalize() in ui/src/theme/context.tsx, which now returns "oc-2" unconditionally. That one function is already on every path — provider init, the mount-time migration, load, the cross-tab storage listener, setTheme, previewTheme — so it needs no second guard, and it gives the migration for free: the existing onMount block already rewrites opencode-theme-id and clears the cached palette CSS when the stored id differs. oc-theme-preload.js does the same rewrite before mount, so an upgrading user never gets a frame of their old palette. Removed surfaces: the Theme row in both Settings screens (with its …/themes docs link), the theme.cycle command and its ⌘⇧T keybind, and the theme.set.<id> entries. The colour-scheme commands stay — ⌘⇧S and the three Use color scheme: … entries still work.

Deliberately left alone

The 36 bundled theme JSON files, the names map, and the LegacyServerLayout branches in app.tsx. That shell still mounts on /:dir routes, and removing either is a large conflict surface against upstream for no user-visible gain.

Tests

legacy-new-session.spec.ts is deleted — it asserted a redirect to a route that no longer resolves. session-timeline-projection needed real work rather than a mechanical fix: it asserted a CommentStrip timeline row, which turns out to be legacy-only (inlineComments is driven by the layout flag, and the new layout renders comments inline on the user message). Rewritten to assert the inline rendering plus the diff-summary overflow, keeping the coverage.

Verification

Drove the built desktop app over CDP: no New layout section; Appearance reads Color scheme → UI Font → Code Font → Terminal Font with no Theme row; data-theme is oc-2, the stored theme id was rewritten and the cached CSS cleared; the served layout.tsx module contains only theme.scheme.* commands.

  • workspace typecheck: 32/32 clean
  • unit: 616 app + 31 browser + 9 ui pass
  • e2e: 87/89 — the 2 failures are in remote-session-settings.spec.ts and reproduce on a clean main, so they are pre-existing and unrelated
  • lint: identical to baseline (one pre-existing error in electron-builder.config.ts)

DIVERGENCES.md gains two rows with conflict-resolution guidance, and CHANGELOG.md an Unreleased entry.

🤖 Generated with Claude Code

quickbeard and others added 3 commits August 10, 2026 16:50
Settings -> General loses the "New layout" switch and Settings ->
Appearance loses the "Theme" picker. The fork ships one layout and one
theme; light/dark stays as the Color scheme axis.

Neither is merely hidden. `newLayoutDesigns` is now a constant rather
than a read of the persisted preference, so a profile carrying
`newLayoutDesigns: false` still comes up on the new layout, and
`setNewLayoutDesigns` / `layoutTransitionAvailable` /
`newInterfaceNoticeVisible` / `dismissNewInterfaceNotice` are removed
from the context rather than stubbed, so re-introducing the toggle is a
compile error. The sunset machinery goes with them.

For themes the lock is `normalize()` in the theme context, which every
entry point already passes through — init, the mount-time migration,
`load`, the cross-tab storage listener, `setTheme`, `previewTheme`. That
also migrates for free: the existing onMount block rewrites a stale
`opencode-theme-id` and clears the cached palette CSS.
`oc-theme-preload.js` does the same before mount so an upgrading user
never sees a frame of their old palette. The `theme.cycle` command
(mod+shift+t) and the per-theme `theme.set.<id>` entries are gone; the
colour-scheme commands stay.

The bundled theme files, the `names` map and the LegacyServerLayout
branches in app.tsx are deliberately left alone — that shell still
mounts on /:dir routes, and removing either is a large conflict surface
against upstream for no user-visible gain.

`legacy-new-session.spec.ts` is deleted (it asserted a redirect to a
route that no longer resolves) and `session-timeline-projection` now
asserts inline comments instead of the CommentStrip row, which only the
retired layout produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four e2e cases in a new layout-theme-lock spec, seeded with the stored
state that used to select the retired layout and themes, plus unit
coverage of the two chokepoints.

Every case was checked to fail when the matching source file is reverted
to upstream's, so none of them passes vacuously:

- old-layout profile boots the shipped layout -> context/settings.tsx
- stale theme migrated before first paint     -> oc-theme-preload.js
- a theme another tab writes is ignored       -> ui theme context
- settings has no switch and no theme picker  -> both settings screens

Two of those needed more than the obvious assertion. The settings case
seeds `layoutTransitionEligible: true`, because upstream only showed the
switch to eligible profiles and without it the assertion passes on
upstream's code too. The migration case watches for the cached-palette
<style> with a MutationObserver installed at document_start: the app
removes that element on its way past, so a post-boot query cannot see
the frame it would have been on screen for. The observer is anchored on
`document` rather than `document.documentElement`, which does not exist
yet that early.

`normalize()` in the theme context becomes the exported
`normalizeThemeID()` returning the exported `SHIPPED_THEME_ID`, so the
single-theme invariant is covered by a test rather than only by its six
call sites. The module imports fine under `bun test` because
`import.meta.glob` is called lazily inside `getFiles()`.

theme-preload gains a fresh-profile case, a system-scheme case and an
explicit "never injects cached palette css" case — the pre-mount timing
the e2e observer watches for, asserted directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit d1feaf4 into main Aug 10, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the lock-layout-theme branch August 10, 2026 10:15
quickbeard pushed a commit that referenced this pull request Aug 11, 2026
#173, #175 and #179 shipped with no tests; #181 and #183 already carried
their own, so this fills the gap left by the other three.

Unit (test-browser, since home-model reaches kobalte through the directory
picker and will not import under the server condition):

- buildHomeSessionRecords: directory filtering, pathKey normalisation so a
  Windows path and a trailing slash are one directory, sort by last update
  with a created-time fallback, dedupe keeping the fresher copy, sandbox and
  projectID resolution, and dropping a session no project claims.
- descendantIDs: the subtree a delete evicts, including cycle safety. Exported
  for this, alongside buildHomeSessionRecords which was already exported.
- session-share-request: the row-to-timeline share handoff consumes once, and
  a request meant for another session is not swallowed.

E2E:

- sidebar-persistence: the column survives opening a session, orders recents
  newest first, opens the session a row names, and View all and New session
  both work from inside a session.
- sidebar-toggle-search: the titlebar toggle hides and restores the column,
  survives a reload and a route change, and Search lands on the recents list
  with its field focused and the ?focus=search param cleared.
- session-actions-menu: the four actions on both lists, Share hidden when the
  server disables sharing, rename in place without opening the session,
  cancel sending nothing, and delete asking first.

Two defects turned up while writing these, both left unfixed and documented
where they bite:

- Clicking a Recents row opens the session but never marks the row as open;
  the marker only appears once the tab list is read back on a fresh mount.
  Arriving by URL marks it immediately, so the state is right and the click
  path fails to re-run the memo.
- The first activation of a row's actions menu is swallowed. Four clicks give
  closed, open, closed, open, and focus + Enter is swallowed the same way, so
  a user's first click on the dots lands on nothing too.

623 unit, 45 browser-condition unit and all 110 e2e regression tests pass;
tsgo -b and the e2e project both typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard added a commit that referenced this pull request Aug 11, 2026
* test(app): cover the sidebar, titlebar and session-row menu PRs

#173, #175 and #179 shipped with no tests; #181 and #183 already carried
their own, so this fills the gap left by the other three.

Unit (test-browser, since home-model reaches kobalte through the directory
picker and will not import under the server condition):

- buildHomeSessionRecords: directory filtering, pathKey normalisation so a
  Windows path and a trailing slash are one directory, sort by last update
  with a created-time fallback, dedupe keeping the fresher copy, sandbox and
  projectID resolution, and dropping a session no project claims.
- descendantIDs: the subtree a delete evicts, including cycle safety. Exported
  for this, alongside buildHomeSessionRecords which was already exported.
- session-share-request: the row-to-timeline share handoff consumes once, and
  a request meant for another session is not swallowed.

E2E:

- sidebar-persistence: the column survives opening a session, orders recents
  newest first, opens the session a row names, and View all and New session
  both work from inside a session.
- sidebar-toggle-search: the titlebar toggle hides and restores the column,
  survives a reload and a route change, and Search lands on the recents list
  with its field focused and the ?focus=search param cleared.
- session-actions-menu: the four actions on both lists, Share hidden when the
  server disables sharing, rename in place without opening the session,
  cancel sending nothing, and delete asking first.

Two defects turned up while writing these, both left unfixed and documented
where they bite:

- Clicking a Recents row opens the session but never marks the row as open;
  the marker only appears once the tab list is read back on a fresh mount.
  Arriving by URL marks it immediately, so the state is right and the click
  path fails to re-run the memo.
- The first activation of a row's actions menu is swallowed. Four clicks give
  closed, open, closed, open, and focus + Enter is swallowed the same way, so
  a user's first click on the dots lands on nothing too.

623 unit, 45 browser-condition unit and all 110 e2e regression tests pass;
tsgo -b and the e2e project both typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(app): drop the workarounds #185 makes unnecessary

Both specs carried a detour around a defect, each with a comment explaining
why. #185 fixes both, so the detours are now dead weight and the comments are
wrong.

- session-actions-menu: openRowMenu clicked a second time when the first was
  swallowed. One click now, and the helper no longer needs the page.
- sidebar-persistence: "marks the row whose session is already open" reloaded
  before asserting data-selected. It asserts on the click that opened the
  session.

The hover in openRowMenu stays. That one is not a workaround: the trigger is
pointer-events-none until its row is hovered, and Playwright hit-tests before
it moves the mouse.

Checked both ways rather than assumed. On #185's branch the three specs pass
20/20; on main without it, both edited tests fail — the menu never reports
aria-expanded=true, and no row is ever marked selected. So this must merge
after #185.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quickbeard <quickbeard@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant