Skip to content

Unify the interface on one design system, and finish the Wake and cc-switch ports - #40

Open
loadchange wants to merge 44 commits into
mainfrom
codex/cc-switch-gateway-refactor
Open

Unify the interface on one design system, and finish the Wake and cc-switch ports#40
loadchange wants to merge 44 commits into
mainfrom
codex/cc-switch-gateway-refactor

Conversation

@loadchange

Copy link
Copy Markdown
Collaborator

Stacked on codex/wake-conversation-refactor, so this PR also carries that
branch's commits; the 19 commits from c20ea40 onwards are the new work.

One visual language instead of three

The window carried Wake's flat warm-grey columns, ccbuddy's white shadowed card
wall and four unrelated accent colours at the same time.

  • Design/DesignSystem.swift is now the only source of colour, type scale,
    spacing, radius and column geometry. The Color.cc* compatibility layer is
    gone, persistent surfaces cast no shadow, and the stray orange resolves to
    Claude clay.
  • Structure from Wake, temperature and accent from Claude, gateway vocabulary
    from cc-switch expressed through the semantic status trio rather than its own
    palette. Written down in docs/design-system.md.
  • Agents are identified by their real brand marks instead of borrowed SF
    Symbols, and the transcript no longer uses emoji as structural icons.
  • A contrast test over every ink/material pair in both appearances found five
    light-mode near-misses between 4.0:1 and 4.47:1; the inks were darkened.

One navigation model

The rail stacked an application list, a compact icon strip, a source-directory
group and a library tree, so the same session was reachable four ways and
counted differently each time. What remains is the library itself, always
listed, with the gateway destinations above it. Settings became a scene reached
from the footer gear and Cmd-,, and history roots moved into it as a
Locations pane where they can be added, revealed, removed and switched off.

Capabilities that were only half ported

  • Continue in terminal — Terminal, iTerm, Ghostty or Warp, using each CLI's
    own resume dialect.
  • Cmd-K search panel — 680pt, keyboard path, brand-marked results.
  • Star and pin — the catalog schema had carried both columns since it was
    created with nothing reading or writing them.
  • Provider presets — ten hand-written entries become seventy, ported from
    cc-switch behind a search field. Their base URLs are normalised to this app's
    contract, which is one path segment longer than cc-switch's; a test pins it.

Fixes found along the way

  • The session stream stopped at 600 entries, hiding 343 of 943 sessions from
    scrolling and search while the badge reported 600.
  • A legacy catalog could never reclaim free pages: auto_vacuum is a no-op on
    an existing database, so incremental_vacuum was inert. A real catalog held
    2.4 GB of freelist inside a 3.8 GB file.
  • The usage scan re-read every transcript on every launch with only an
    in-memory cache. Measured on a 14 GB library: 241 s -> 6 s, peak memory
    841 MB -> ~460 MB, from a 15 MB per-file record cache.
  • Session titles came from carried tool output, so sessions were named things
    like Script completed Wall time 1.8 seconds Output: {"plan":{}….
  • One unreadable transcript out of a thousand painted the column header red and
    raised an error toast.
  • Ten pieces of interface copy were still leaking Chinese to other locales.

Verification

  • 489 unit tests pass.
  • Three gateway E2E classes are excluded from that run:
    BifrostSupervisorIntegrationTests, BifrostResponsesProtocolIntegrationTests
    and ClaudeCLIBifrostE2ETests hang indefinitely on this machine. Reverting
    the branch's pre-existing failover changes does not help and bifrost-http
    starts standalone in 4 s, so the cause is environmental rather than in this
    work — but it needs confirming in CI.
  • DesignProofSheetTests renders the palette, controls, rail rows, session rows
    and a transcript off-screen in both appearances, which is how the duplicated
    subagent arrow and the emoji icons were caught.

Known gaps

  • The cc-switch failover port already present on this branch has a data path but
    no UI and no tests; it stays off by default and is deliberately not exposed.
  • Per-location toggles ship, but Wake's per-agent grouping inside the Locations
    pane does not.

The window carried three visual languages at once: Wake's flat warm-grey
columns, ccbuddy's white shadowed card wall, and four unrelated accent
colours. Fold them into one set of tokens and one navigation model.

- Design/DesignSystem.swift is now the only source of colour, type scale,
  spacing, radius and column geometry; Components.swift holds the shared
  controls. The Color.cc* shim is gone, persistent surfaces cast no shadow,
  and the stray orange accent resolves to Claude clay.
- One rail instead of three stacked navigations. The duplicate icon strip
  and the source-directory group are gone; history roots moved to the new
  Settings > Session locations pane, and Settings is a scene reached from
  the footer gear and Cmd-, rather than a peer destination.
- Agents are identified by their real brand marks (from Wake) instead of
  borrowed SF Symbols.

Finish the halves of both ports that were never wired up:

- Continue a session in Terminal, iTerm, Ghostty or Warp using each CLI's
  own resume dialect.
- Star a session; the index has carried the column since day one with
  nothing reading or writing it.
- Port cc-switch's provider catalog: ten hand-written presets become
  seventy, behind a search field. Their base URLs are normalised to this
  app's contract, which is one path segment longer than cc-switch's.

Fixes found along the way:

- The session stream stopped at 600 entries, hiding 343 of 943 sessions
  from both scrolling and search while the badge reported 600.
- A legacy catalog could never reclaim free pages: auto_vacuum is a no-op
  on an existing database, so incremental_vacuum was inert. A real catalog
  held 2.4 GB of freelist inside a 3.8 GB file.
- Session titles came from carried tool output rather than typed prose.
- One unreadable transcript out of a thousand painted the column header
  red and raised an error toast.
A contrast test over every ink/material pair in both appearances found five
light-mode near-misses, all between 4.0:1 and 4.47:1 where body-sized text
needs 4.5:1 — secondary text on the two darkest fills, clay text on the
selection wash, and the success and warning inks on their own tints.

The materials are intentionally close in tone so columns can separate
without borders, which is exactly what makes this easy to get wrong by eye.
Darkening four inks by a few percent clears the threshold and keeps the hue.
Wake's Locations pane can silence a source without forgetting it; ours could
only add or remove, so the only way to stop scanning a tree was to delete its
configuration and re-add it later.

A disabled location keeps its entry and its catalog rows — reconciliation is
per scope, so nothing is discarded — and drops out of scanning, watching,
listing and search. Switching it back on picks up incrementally rather than
rebuilding. The row recedes as a whole instead of gaining a "Disabled" badge.
The catalog schema has carried a `pinned` column since it was created, with
nothing reading or writing it — the same gap the star had. A pinned session
holds the top of the stream regardless of when it last ran, marked in the
row and toggled from the reading header.

Star and pin are separate facts, so each patch names only its own field.
Cmd-K opened nothing; the rail's inline field searched the whole library from
a control that looked like it filtered the column beside it.

The rail row now opens Wake's panel — 680pt wide, large borderless input,
brand-marked results with a snippet, and the keyboard path spelled out in the
footer. Arrow keys, return and escape run through a local event monitor so the
field keeps first responder while the list is driven from the keyboard; the
deployment target predates onKeyPress and a focused field eats arrow keys
before a keyboardShortcut would see them.

The panel reuses the store's existing full-text search rather than opening a
second search path, so the stream stays filtered to the same query afterwards
and the rail row shows what that query is.
ImageRenderer draws through Core Graphics rather than the window server, so
the palette, the control kit and the rail rows can be inspected in both
appearances over SSH, on CI, or while the screen is locked — which is exactly
when a visual regression is otherwise invisible.

Scope is deliberately the design system and not whole panels: AppKit-backed
controls come out as placeholders and ScrollView content does not draw, so a
blank area in a panel sheet could not be told apart from a real regression.

Opt in by creating /tmp/ccg-proof before running; an ordinary run stays quiet.
Two of the strings added in the previous commit already had translations in the
shared dictionaries. Overrides win over the dictionary, so adding them silently
rewrote copy that other locales had already settled on — a Japanese assertion
caught one of them.

Only strings the dictionary does not already cover belong in the override file.
The page was titled "About and updates" and contained only an update button.
It now opens with Wake's identity block — product mark, name, running version,
one line of what the app does, the repository, a short rule, then the license
and where the data lives — before the update controls.
The stream row is the most repeated element in the application, so it gets a
proof sheet of its own: pinned, starred, subagent, a search hit and an untitled
session, in both appearances.

The sheet immediately showed the subagent arrow drawn twice — once at the head
of the title and again in the metadata line, where it added nothing. Only the
first survives, and it now carries the label it always should have.
Role headers and tool rows used emoji as structural icons — 💭 ✏️ 🔎 🔧 and the
rest — which is the one thing the design system rules out for interface icons:
they vary in weight, colour and baseline, so every row in a transcript looked
like a different kind of thing.

Roles are now marked the way the rest of the app marks them: the person icon
for you, the agent's own brand mark for the assistant. Names are no longer
upper-cased, which did nothing to Chinese and shouted "CLAUDE CODE". Tool rows
carry monoline symbols chosen to say what the tool did.

A proof sheet covers the exchange so the reading card can be checked off-screen.
Qoder was the one agent still falling back to a lettermark. Its icon ships as a
full app icon with an opaque background rather than a trimmed glyph, so brand
marks now carry a flag for that shape and clip it to the squircle macOS gives
app icons — drawn flat it would sit on the warm materials as a hard-edged block
of its own background colour.
The usage scan collected every parsed record from every transcript before
aggregating, so peak memory tracked the size of the library rather than the
size of the result. On a 14 GB library that was 841 MB resident for a few
hundred day buckets; it now stays around 180-290 MB.

Deduplication sees the same records in the same order — it is the same rule,
applied one record at a time instead of over a materialised array — so the
aggregate is unchanged.
The rail listed "Sessions" as a destination and then, below it, the library
that destination shows — a second door into the room you were already standing
in. The row is gone; the library is always listed, and picking any scope in it
is what takes you there.

Two things had to follow. The catalog now activates with the shell rather than
with the sessions page, or the resident rail would show empty agent and project
groups everywhere else. Import and refresh moved out of that same conditional
for the same reason. A scope still only reads as current while sessions are
actually on screen.
The usage scan read every transcript in every configured location each time the
app started, and the only cache was in memory — so a 14 GB library was read
again from scratch on every relaunch. Measured here: 241 s of ~95% CPU before
the panel showed a number.

Parsed records are now kept per transcript, keyed by modification time and
size, in ~/.ccbud/usage-records-v1.json. Records rather than day buckets,
because deduplication runs across files — an assistant message appears in both
a parent transcript and its subagent's — so the fold still has to see every
record. Unchanged transcripts are answered from the cache; changed ones are
parsed again; entries for deleted transcripts are dropped on commit.

Same library, second launch: 241 s -> 6 s, from a 15 MB cache. The cache is
disposable: losing or corrupting it costs one rescan and nothing else.
An earlier scripted edit dropped the Monitor page's padding entirely, leaving
its content flush against the header's hairline and against the window edge.
The inset is now one modifier the three gateway pages share, so it cannot go
missing from one of them again.

The metric strip was four fixed columns that collided with themselves on a
narrow window — the endpoint and the timestamp had nowhere to go. It folds to
two by two when there is no room for four.

The library group of producing CLIs is called Tools, not Agents.
The sheets opted themselves out with XCTSkip when nobody had asked for the
files, which broke the native gate: it requires zero skipped tests, and CI
reported 500 passed, 0 failed, 5 skipped — exit 1.

They now always render, which is the part worth having anyway: it proves each
surface still lays out and draws, and the render is asserted to produce a real
image. Only writing the PNGs is conditional on somebody wanting to look at them.
Sessions is no longer a destination row, so the localized-navigation test was
asking for a button that cannot be there, and the navigation walk was clicking
it. Both now cover the three gateway rows plus the footer gear, and the walk
enters the library the way people do — through its "all sessions" row.

Two robustness fixes alongside: the session column is an accessibility element
in its own right, because a bare identifier on a container propagates into its
descendants and the column matched several elements at once; and the providers
assertions wait, since the shell appearing does not mean the destination inside
it has laid out.
One shared `accessibilityContainerIdentifier` replaces the three per-area
copies. It is the marker form throughout: SwiftUI pushes a plain identifier
placed on a container down onto its descendants, where it replaced their own
hooks, so Providers answered to `view.providers` for its hero and its buttons
alike.

The shell marker moves onto the background layer, which is the layer that
actually spans the window; hung off the outer chain it measured the safe area
and reported the shell starting a title bar below the window top. The window
pixel samples now read the palette they are checking and are no longer flipped
— counting rows from the bottom, the detail column reported its reading card
instead of its title-bar band, which is what hid the flip until now.

The Claude/ChatGPT analysis actions live in the overflow menu since the header
gained its terminal-resume primary; the test opens the menu instead of looking
for toolbar buttons that no longer exist.
The config and the Bifrost route builder already modelled an ordered failover
queue, but nothing in the interface could turn it on or say what was in it, so
the feature existed only for anyone hand-editing config.json.

Gateway settings gains the queue: a switch, the ordered entries with their
priority, and add/reorder/remove. Order is edited here rather than inherited
from the provider list, because for this queue the order is the entire meaning.
Providers carry a P1/P2 badge while failover is on, so the list explains why a
request went where it did.

Enabling with an empty queue seeds it with the active provider, and removing
the last entry reseeds it — an enabled queue is the gateway's whole route set,
so an empty one would mean no route at all.

The overflow menu in the conversation header takes a coordinate click: a menu
in a window that is not key reports itself unhittable on a headless runner.
A single coordinate click on a menu button in a window the runner has not
brought forward opens nothing, which is how the assertion after it failed on CI
while passing locally.
Three leftovers from the pre-indexed history path and the pre-design-system
views: HistoryRepository's own Codex canonicalisation, project ordering and
ancestor-preserving limit — 234 lines duplicating what HistoryCatalogProjection
now does for both the repository and the catalog — plus an HTTP response
scanner superseded by the rewriter and two view helpers with no call sites.

Also gives the failover queue's add control the same quiet-button shape as
every other action on the settings page.
A SwiftUI Menu publishes its items only once AppKit opens it, which the CI
runner never does regardless of how the click is delivered; the actions behind
it are covered by ConversationReplayTests.
The gate required exactly ten UI tests, so the eleventh failed the build by
existing. Assert the property instead: every test that ran passed and none were
skipped, with a floor that still catches a suite that stopped running.
An enabled queue routes every request in order, so its first entry is the
provider in use — but the provider list took its badge from activeProviderId,
which nothing tied to the queue. Enabling failover, reordering it, or removing
its head could each leave one row marked 使用中 while a different row took the
traffic.

Normalisation now makes the head authoritative while the queue is routing, and
choosing a provider from the list moves it to the front instead of setting a
field the gateway ignores.
With no sessions to list, the workbench showed two empty states side by side:
one saying there is nothing to read, the other asking you to pick something
from it. The list keeps the explanation and the canvas stays plain.
The snippet generated a fresh bundle identifier per run, which is a fresh TCC
subject each time and so a consent prompt each time, and it omitted the signing
flags without which the runner cannot attach at all.
It failed once on a loaded runner with two terminal events and a count from
some other scan, and the assertion messages said nothing about which scan
produced what.
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