Choosing a directory in a fresh session cost two clicks. The first opened the
run-location pill's menu, the second opened a directory picker nested inside
it — and in that state the menu in between held exactly one row.
That menu was built around a repo it already had: directory, branch and both
worktree modes. With no workDir yet `isGitReady` is false, the latter two are
gone, and the root view collapses to a lone "Directory" row whose only job is
to open a second dropdown. A directory that is not a repo collapses the same
way.
The directory list is a view of this menu now, symmetric with the branch list:
the menu opens on `root` when there is a repo to describe and on `directory`
when there is not. Its back crumb appears only once `isGitReady`, because
going back without one lands on the single-row shell this exists to skip.
Picking a repo holds the menu open and swings back to the root view, where the
branch row and worktree cards have just appeared. They were invisible before
to anyone who did not go looking for them, since picking a directory closed
the whole menu. A plain folder has no next step, so it closes instead, and a
skeleton holds the height while the context request is in flight.
Resolving that pending pick needs the identity check `context.workDir ===
revealAfterPick`. On the render right after a pick, `context` still describes
the previous directory and `loading` has not flipped yet, so a bare `!!context`
judges the new folder by the repo it replaced — and clears the pending state,
so nothing ever corrects it.
Extracting the list as `RecentProjectsPanel` also drops the nested-picker
outside-click exemption (no second portal left to exempt) and the `menuitem`
variant (no callers left). The native folder dialog now funnels through the
same selection path it was bypassing: it called `onChange` directly and left
the recent-project cache stale.
The composer's send button was a 112px pill reading "run" with a trailing
arrow, in a toolbar where every other control is already icon-only and where a
model picker and a location chip have to fit beside it. It is now the round
up-arrow the rest of the category uses. The arrow points into the transcript
the message is going to, which is what lets it read as send with no word next
to it; the name lives in aria-label and the tooltip, at every width.
Both composers change together — the in-session one and EmptySession's. They
were already deliberate mirrors of each other, and the note in ChatInput
recording why is now the note explaining the shape.
Adds shape="circle" to Button rather than a tone to IconButton, which already
has a circle and forces a label. Two things ruled IconButton out. It dims to
opacity-50 when disabled, and half-transparent ink over the page ground reads
as "still loading" rather than "not available" — the exact reason primary
carries an opaque disabled fill, and the send button sits disabled whenever
the input is empty. And --color-inverse-surface is already btn-primary's own
--cc-t1, so reproducing primary's colors there would have forked that
definition in two, past the token contrast guards.
No new colors: primary is --cc-t1 on --cc-bg, which inverts on its own. The
four paper themes give a dark ink circle with a pale arrow, dark and ink-blue
a pale circle with a dark one. Measured across all six, arrow-on-circle runs
12.70:1 to 16.98:1, and hover resolves to --color-brand throughout.
The radius moves out of BASE_CLASSES so exactly one rounded-* is ever emitted;
the component does no Tailwind conflict resolution, so keeping both would have
left the shape to stylesheet order rather than to the prop (AGENTS.md 3.6).
A test pins that.
Drops iconOnlyAction and the 610px threshold behind it. It existed to shed the
run button's label before the location degraded, and there is no label left to
shed, so the location is now the only thing that degrades.
Also covers the stop button, which had no test at all: send and stop are one
control that swaps role, so a round send becoming a pill on stop would shift
the whole toolbar every time a turn starts.
The rightmost tab lost its right edge whenever it was the active one, close
button included. Not merely hidden either: the button's centre sat past the
strip, so elementFromPoint there returned the toolbar's terminal button and
that tab could not be closed at all.
The activation scroll is the cause, but only together with the chevrons. They
are w-7 siblings of the scroll region, so the moment updateScrollState decides
the strip overflows they take 28px each out of a flex-1 region — after
scrollIntoView has already landed on a scrollLeft computed without them, and
scrollLeft does not follow a layout change. Measured on a 1280px window with
seven tabs: the scroll stopped at 108 when the reachable end had moved to 164,
and the tab lost exactly those 56px.
So keeping the active tab whole cannot be a one-shot on activation; it is an
invariant the strip has to re-establish whenever its own width changes. It now
runs off the ResizeObserver that was already there, which covers window
resizes, sidebar drags and the toolbar's conditional buttons for free.
Guarded on whether the user has driven the strip themselves, not on the strip
getting narrower. Width was tried first and is wrong: a chevron retires when
its end is reached and rejoins when it is left, so a plain chevron press
narrows the strip mid-flight and is indistinguishable from the layout event
being guarded against. Measured, that snapped the view straight back and made
the left end unreachable. Switching tabs hands the position back — the user
has just named a tab they want to see.
Four tests, two of them pinning the guard rather than the fix. Sentinel runs
confirm both halves: removing the realign reddens two, removing the guard
reddens the one written for it. The ResizeObserver mock now records its
callback, since jsdom lays nothing out and the geometry has to be stubbed
alongside it.
Walked through against a live strip: rightmost tab clip 56px -> 0, close
button reachable again, left end reachable, sidebar expand re-aligns by 136px.
The rounded tab's whole premise is that its paper fill runs unbroken into
the view it opens onto. Settings was the one place that did not hold: the
selected white tab sat directly on a grey panel and read as a card
stranded on it.
The cause is not in the tab strip. The settings rail is painted
--color-surface-container-low, which resolves to the same --cc-s0 the
strip's trough does, so the trough appeared to wrap around the corner and
continue down the left of the page with the tab marooned in it. Every
other page — chat, market, scheduled, trace, subagent — is already paper
at the top, which is why only this one showed.
So the rail moves onto paper and the existing border-r does the
separating, which is what every other secondary panel in the app already
does (the workbench, the diff split). The rail was the odd one out.
That also fixes something nobody had flagged: the selected rail entry is
--color-surface-hover, which against the old grey was 1.06:1 — a
highlight you cannot see. On paper it is 1.15:1.
ring-offset moves with it. It is a painted ring, not a transparent one,
so naming the old fill would have drawn a grey collar around the focus
ring on a white rail.
Two alternatives rejected: --color-surface-container (#FAFAF8) measures
1.045:1 against paper — indistinguishable, so it buys a special case and
nothing else; and having the tab's fill follow whatever page is below it
would leave Settings' tab with nothing but an outline, dropping the
selected state entirely.
Tabs were hard to tell apart. The strip, the selected tab and the content
below it sit within 1.05–1.10:1 of each other in all six themes, so the
only thing marking selection was a 3px terracotta rule along the bottom —
and nothing at all separated two idle tabs from each other.
The obvious fix is Chrome's: darken the strip into a trough so the paper
tab pops out of it. That is the wrong trade here. The strip is continuous
with the sidebar, and darkening it turns the titlebar into a separate band
running across the top of the window instead of the same surface the
session list is already on. So the trough stays exactly --cc-s0, the fill
contrast stays where it was, and the shape is carried by two new per-theme
tokens instead:
- --cc-tab-edge (1.35:1 against the trough) outlines the selected tab.
Without it the 8px top corners do not resolve at all — a curve is drawn
by a colour boundary, and on 素白 there is none. This was measured, not
assumed: a radius-only build is indistinguishable from the flat strip.
- --cc-tab-sep (1.22:1) is the hairline between two neighbouring idle tabs,
and the same rule now divides the toolbar off (it was a full-height
border-l, which read as a different kind of divider next to 16px ones).
Neither can reuse --color-border or --color-outline. Both are calibrated
against paper; on the trough they land at 1.12:1 (invisible) and 1.36–2.01
(a drawn box), in opposite directions between the light and ink families.
Both new tokens go *lighter* than the trough on dark and ink-blue for the
same structural reason --color-surface-hover was already banned here:
their paper is dark enough that pure black is only ~1.26:1 against it, so
there is no room downward.
Hover gets the weaker of the two outlines rather than none, giving three
legible tiers — no outline, hairline, full edge — because the same
1.05–1.10:1 that hides the selected tab's corners hides a hovered tab's.
Two things had to go for the corners to mean anything. The terracotta rule
and the strip's border-b both cut across the bottom edge that now has to
run unbroken into the content the tab opens onto; a rounded tab sitting on
a ruled line is just a clipped rectangle.
Chat tabs also lose the chat_bubble glyph. #1123 asked for icons and got
one on every kind including session, which is most of the strip, so the
row filled with identical bubbles saying nothing the titles did not. The
glyph now means "this tab is not a conversation". The slot animates its
own width rather than collapsing outright, so a session starting up still
does not jump its own title sideways — the bug the fixed slot was added
for. Spacing moved to per-child margin for the same reason: flex gap is
charged between children whatever their width.
contrast.test.ts gains 18 assertions pinning both tokens per theme,
including an upper bound on the hairline — too strong and the strip reads
as a table of cells. The three TabBar guards written against the old
square shape (no radius, 52px tab, the terracotta rule) are rewritten
rather than deleted, since what they were protecting — this is a document
tab, not a floating pill — still holds.
The header rendered both "Claude Code Haha" and "cc-haha" and hid one
with a container query, so the app answered to two names depending on how
far the sidebar had been dragged. Keep the short form at every width and
remove the node rather than hide it — a display-hidden copy still reaches
screen readers and in-page search.
The composer took `compact` straight from "is the workspace panel open",
so opening the panel dropped the run location to a second line and shrank
the permission mode to a bare icon on columns with hundreds of pixels to
spare. The panel is resizable and the window is not fixed, so its open
state says nothing about the width the composer actually got.
Measure the shell instead and degrade in two steps: the run button gives
up its label at 610px, the location leaves the toolbar at 530px. The
numbers come off the shipped toolbar with the longest mode label. Within
the location chip the branch now yields width before the project name,
which used to truncate both at once into `cc-…/…n`.
Confirming the edit bubble did nothing: the page emitted picker-exited
before selection, so both host-side guards added by 8ec8833be disarmed
first and threw the selection away — the main process returned before
even capturing, and the renderer's pickerActive check would have dropped
it too. selection already implies "this pick is over" and both hosts
reset their picker state on it, so the confirm path now only cleans up
locally; picker-exited stays on the cancel/abort paths that produce no
selection. The authorization semantics are unchanged.
The bubble is also rebuilt: colour fields get a picker swatch plus hex
(with a chequerboard for transparent), opacity becomes a slider, font
becomes a select, styles move to a constructable stylesheet so a strict
style-src CSP can no longer blank them, and the panel follows the system
colour scheme. Text editing now reads and writes .value on form controls
— it was always blank on inputs — and is withheld from containers where
it would flatten the subtree.
Also fixes opacity '0' being swallowed as falsy in applyEdit, and a
phantom diff when the picker re-picked an already-set colour.
The confirm path had no test at all, which is how the ordering bug
shipped; add page-level coverage of the real message sequence plus
ordering contracts on both hosts.
A file path the assistant printed in prose was inert text, so the only
way to reach the file it named was to retype the path into the file tree.
The reported request — "recognise file paths in the body" — understates
what was missing. src/constants/prompts.ts:437 already instructs the
model to write file_path:line_number "to allow the user to easily
navigate to the source code location", and :438 to write owner/repo#123
"so they render as clickable links". The model has been holding up both
ends all along; neither had an implementation on the desktop side.
This is the second half of #1145 and reuses its four layers: boundaries
live in a pure filePathBoundary module beside urlBoundary, marked
integration joins markdownAutolink, clicks inherit openPreviewLink
unchanged, and the styling follows prose-a and md-code-link.
Boundaries invert #1145's approach. A URL is matched permissively then
trimmed, while a path segment is an allow list, so prose punctuation
cannot leak into a path the way a sentence leaked into an href. CJK is
excluded from that list on purpose, which costs 文档/说明.md: allowing it
would make 修改了lib/foo.ts match from 修 and drag the verb into the path,
and Chinese running flush against an ASCII path is far more common than a
CJK filename. Extensions are gated by one set shared with
previewLinkRouter, so anything underlined in the prose is guaranteed to
have a route that opens it — the old private list in that module was
missing .yml and .ps1, which classified as ignored and did nothing when
clicked. A bare foo.bar additionally requires an extension that cannot be
read as a property access, since console.log, array.map and process.env
are otherwise indistinguishable from filenames.
Windows had no working path link at all. new URL('C:\\src\\app.ts')
succeeds with protocol 'c:', so every drive path fell through to ignored
— on the platform this issue was filed from. The drive check now runs
before URL parsing.
References carry their target in data-* rather than href. The sanitizer's
ALLOWED_URI_REGEXP reads a leading word: as an unknown scheme, which
strips exactly two shapes: foo.ts:42 and C:\src\app.ts. Widening that
pattern is not an option — it is what keeps javascript: out — so the
anchor gets role and tabindex for the affordances an href would have
provided, and fileRefFromElement rebuilds the reference on click, leaving
classifyPreviewLink the single parser for both these anchors and
hand-written markdown destinations.
A reference becomes a link only where a click can be handled. Eleven of
this component's thirteen callers — release notes, agent prompts, thinking
blocks, plan previews, the markdown file preview — pass no handler, and a
link there looks live and does nothing. That same condition closes the
streaming gap: MessageList's streaming renderer gets no sessionId, hence
no handler, so a paragraph the model finished before calling a tool stays
plain even though chatState has already left 'streaming'.
Bare paths additionally wait for the text to be final. A path has no
closing delimiter, so mid-stream desktop/src/lib/foo.ts is itself a valid
reference that changes again as x and :42 arrive, flickering through three
targets on one line. This is why recognition runs on the DOM after
sanitizing instead of as a marked tokenizer: marked.use is module-level
and cannot be gated per render. Working on the DOM also puts URLs out of
reach, since they are already anchors by then, so the github.com/a/b.ts
inside an href can never be re-matched as a path. Inline code needs no
such wait — an unclosed backtick is not a codespan, so the reference is
whole by the time it renders — and because renderCodespan sits on marked's
shared renderer and cannot see whether a surface handles clicks, its
anchors are unwrapped on the surfaces that do not.
Clicking reveals the referenced line rather than only opening the file.
The preview truncates past WORKSPACE_PREVIEW_LINE_LIMIT, so a reference
beyond the fold points at a row that was never rendered and would
silently do nothing; the reveal expands first, and its effect is declared
after the reset effect so a reload cannot collapse it again. The mark's
load-bearing part is an inset rule in --color-brand: measured across all
six themes, every soft fill lands between 1.02 and 1.11 against
--color-code-bg — 1.05 for --color-brand-soft in warm-classic, the
default — so no tint in this palette can carry it alone. contrast.test.ts
guards the rule, the same way #1145 guards the underline.
Right-clicking a reference opens the menu the output cards and file tree
already use, extended with copy-path and copy-contents. The file tree
passes omitCopyPath because it renders its own pair above that block. The
store wiring those two call sites each spelled out is now a single
openWithMenuItems; only the wiring is shared, not how targets are fetched,
since the tree holds them from a selector and must stay synchronous or its
menu renders a frame short.
Mistyped references are not pre-validated, so nothing is silently left
unlinked; a path that does not exist opens and reports "file not found",
which the preview already handled.
Bash/PowerShell cards printed the command three times (collapsed header,
terminal card, Tool Input JSON) and never printed its output. Echo the
command's output into the terminal card and drop the duplicated JSON.
- Render shell output as plain preformatted text, head-windowed to 12
lines behind a toggle. Errors are never windowed, preserving #625.
- Recognise the CLI's `(<Tool> completed with no output)` substitution
(toolResultStorage.ts, inc-4586) instead of testing for empty content:
empty content never reaches the desktop, so the marker would otherwise
be rendered to the user verbatim.
- Show only the non-echoed input keys as JSON, so `timeout` and
`run_in_background` stay visible without reprinting the command. An
all-or-nothing rule left the command triplicated for the ~20% of real
calls that carry a `timeout`.
- Resolve terminal control sequences: carriage-return overwrites, and the
full CSI and OSC families. Stripping only SGR colour codes left erase
and cursor sequences to print literally, and a carriage return left at
a collapse boundary added a blank line on Windows.
- Distinguish an image/structured result from a silent one, so an
image-only command is not labelled as having printed nothing.
- Add a duration badge. Note this measures the transcript gap, which
begins when the model starts streaming the tool input; the CLI does not
report execution time over the wire, and parallel calls in one batch
share a single tool_result timestamp.
Read, Edit and Write results stay suppressed as before.
Tested: bun run check:desktop (281 files, 3540 passed, 1 skipped, build ok)
Tested: mutation-tested the new assertions to confirm they fail when the
corresponding logic is reverted
Tested: real-render walkthrough against live CLI payload shapes (marker
string, CRLF output, ANSI progress frames)
Confidence: high
Scope-risk: narrow
(cherry picked from commit 618a9a063e189d49efa7f27d36c2b33cc67c7aed)
A URL the assistant printed in prose was either not a link at all or a
link pointing somewhere else, and the only clickable copy lived in the
output card below the reply. Five separate causes, four of which only
show up in Chinese text.
The reported cause — "gfm autolink is off" — was not one of them; it has
always been on. What is wrong is where GFM ends an autolink: the spec
trims only ASCII trailing punctuation, so a full-width mark or a Han
character right after the URL is treated as part of it. "打开
http://localhost:5173,然后刷新页面" became a single link whose href
carried the rest of the sentence, and clicking it loaded a 404 in the
workbench browser. The output card was unaffected because
assistantOutputTargets already excluded CJK punctuation — which is
exactly why the card looked like the only thing that worked.
Boundaries now come from a shared urlBoundary module built on two
structural rules: after the authority a URL can only continue with / ? #,
so a Han character there ends it; and inside the path CJK letters are
legal — /文档.html is a real path — while CJK punctuation is not. It is
installed by overriding marked's `url` tokenizer, which falls back to the
built-in one for schemeless www. hosts and bare email addresses. IPv6
literals are left to that fallback and stay plain text as before:
marked's cleanUrl percent-encodes the brackets, and the resulting href
fails new URL(), so classifyPreviewLink would route it nowhere.
The prompt bubble rendered raw text, so a URL the user typed was never
clickable. It now splits bare URLs out and wraps those, without going
through the markdown renderer — a prompt is literal text, and `**`, `#`
and file paths have to survive as typed.
Inline code that is nothing but a URL is now a link and keeps its code
chip. `curl http://localhost:3000` is a command, not a link, and stays
plain code.
Links also could not be recognised as links. The accent measures only
1.95–2.55:1 against body text across all six themes, so in dense Chinese
prose an unadorned link is indistinguishable from the sentence around it
— the reported "same colour as the body text" was literal. Links now
carry a resting 1px underline in the accent, and theme/contrast.test.ts
guards that it stays visible: the first attempt used
--color-primary-fixed-dim, which measures 1.49–1.76:1 against the page
and would have shipped an invisible fix.
On the CLI the OSC 8 machinery was already in place; bare URLs just never
reached it. Markdown's fast path skips marked.lexer entirely when it sees
no markdown markers, and a plain Chinese sentence carries none — so the
URL rendered as dead text, while the same sentence in English usually
contained a hyphen and linked fine. Content carrying a scheme now always
reaches the lexer, scanned across the whole string rather than the
500-character sample, since a summary commonly puts its dev-server URL in
the closing line. OutputLine's linkifier had the same ASCII-only
boundary flaw and now shares urlBoundary.
Routing is unchanged: the markdown body's anchors already went through
handlePreviewLink, so loopback URLs open the workbench browser and remote
ones the system browser. The store wiring that AssistantMessage and
AssistantOutputTargetCard each spelled out separately is now a single
openPreviewLink, which the prompt bubble reuses.
Reading "when did this finish" and "how long did it take" required
hovering the reply, which a touch user cannot do at all and which nobody
thinks to try on a turn that ran for twelve minutes.
The turn's last reply now carries an always-visible stamp under it —
"Done 15:20 · took 12m 19s" — while every other message keeps the
hover-only timestamp it had. That reply drops its own hover chip, since
the stamp already states the same time a line above it.
A turn is stamped only when it ends on a reply. Turns whose last reply is
a mid-turn aside followed by more tool calls are left alone: the stamp
renders under the reply, so it would sit above the work it introduced.
Trailing task summaries and background-task cards are the exception —
the summary is written on the next send and background work is detached,
so neither means the answer was still coming. The running turn is never
stamped, queued prompts do not close the turn that is still streaming,
and a span above a day drops the duration and keeps only the end time
(that is a session resumed the next day, not a model that thought for
eighteen hours).
Durations reuse the existing chat.duration.* strings rather than a
fourth private formatter, and StreamingIndicator now shares them so the
same turn does not read as "12m 19s" while running and "12 分 19 秒"
once done. Those strings gained an hours tier: a long turn used to
render as "75 分 30 秒".
History goes through the same derivation as live turns, so reopening an
old session shows the stamps too.
Treat the full two-pixel WebView2 resize oscillation as layout jitter while preserving accumulated follow behavior for real content growth. Add a regression covering stepwise one-pixel observations across both edges.
Two additions to the provider settings page, both modelled on cc-switch.
One-click import from cc-switch:
- Reads the local cc-switch installation and offers its Claude Code
providers for bulk import. SQLite (cc-switch v3.8.0+) is the primary
store, with the legacy v2 config.json as a fallback used only when no
database exists — once cc-switch migrates it archives that file, so
reading it alongside a database would surface pre-migration data.
- Supports cc-switch v3.1.0 and newer. Older installs wrote a config
format cc-switch itself dropped in v3.6.0; those are refused explicitly
rather than reported as an empty scan.
- Degrades honestly when cc-switch's storage moves: a structure we cannot
read reports why, distinguishing "cc-switch too old" from "cc-haha does
not recognise this layout" from "the file could not be read at all".
Only id/app_type/settings_config are required; other columns are
optional and unknown ones are ignored.
- Full credentials are resolved server-side during import and never
appear in the scan payload.
Fetch model lists:
- Probes the provider's Base URL for an OpenAI-compatible /models
endpoint, walking cc-switch's candidate ladder (version segments and
nine vendor compat suffixes) and falling through on 404/405.
- Only http(s) endpoints are fetched; a 2xx that carries no model list is
reported as a failure with the upstream's own message rather than as an
empty catalog, so a key rejected behind a 200 is not read as "this
provider has no models".
Dragging clamps against the mascot alone, so the mascot can reach a
display edge through the window's transparent padding. At the top edge
that means asking for a negative window y on purpose -- and the activity
panel lives in exactly the padding that goes off-screen with it. Measured
against the shipped layout: of a 96px panel, 78px ends up above the work
area, leaving an 18px sliver under the menu bar.
This is not a regression in 8f3a2f092; it is that fix's other half. The
mascot reaching the menu bar and the panel following it off-screen are
the same negative y.
So the panel changes sides instead. The main process is the only side
that knows the window position and the work area, so it decides and the
renderer follows, the way the Codex overlay does it.
Three things that are load-bearing:
- The test is placement-independent -- panel height against the room
above the mascot -- because the flip frees the very space a
"does it still fit above?" test would measure next, and would then
flip back once per frame. A 24px hysteresis covers the boundary.
- Flipping moves the mascot inside the window, so the window moves the
opposite way to hold it still on screen. Mid-drag that has to rebase
the drag's window origin too, or the next tick recomputes the pre-flip
position. A restore needs the same treatment: a saved y belongs to the
mascot offset it was saved with, and the renderer always starts the
panel above, so restoring the bare window position would drop the
mascot by the panel's height on the next launch.
- The renderer only sends drag start and end -- the cursor sampler in
this process drives everything between -- so a flip decided mid-drag
has no reply to ride back on and goes out as an event.
The panel box is the union of every reported region past the mascot,
which keeps the IPC payload shape unchanged.
Left and right are deliberately untouched. The panel is 352px wide in a
384px window, so it can only slide +/-16px before the window itself
clips it, while reaching a side edge needs about 120px. Those need the
window to grow or move, which is a different change.
Falsified each layer by reverting it: the placement test, the window
compensation, the drag rebase, and the restore anchor each turn their
own case red.
Opening a long-finished session showed it "start talking again": dozens
of `已思考` bubbles streaming in, nothing rendered between them. Nothing
was actually re-run — the transcript stops at the moment the turn ended
and the trace holds only the original 13 API calls. The whole wall is a
replay of output that had already been rendered hours earlier.
The source is not in this server at all. `WebSocketTransport` (inherited
upstream, used for the CLI's `--sdk-url` connection) buffers every
outbound message that carries a uuid, and on every successful reconnect
replays that buffer from the start — `onBunOpen` passes an empty
`lastId`, which skips the branch that would evict already-confirmed
messages, and `replayBufferedMessages` deliberately does not clear the
buffer afterwards. It is safe to do that only because the code assumes
"The server deduplicates by UUID". We never implemented that contract:
`X-Last-Request-Id` appears nowhere outside the transport itself, and
the one uuid check in `handler.ts` only guards task-notification
persistence and forwards regardless. So each reconnect pushed the whole
window through untouched.
The transport also detects system sleep explicitly and keeps resetting
its reconnect budget, so a closed laptop guarantees the reconnect rather
than preventing it. The diagnostics for the reported session (pid 58975)
recorded 31 sleep detections and 31 replays of 858 messages each, spread
over the ten hours between the turn ending and the session being opened
— 13 thinking blocks re-delivered 31 times. Across this machine the same
event has fired 10811 times; it has been happening all along.
Only thinking showed it. Replayed user messages are idempotent, replayed
tool calls are upserted by `toolUseId`, replayed tool results are folded
into the tool card and stay invisible, and replayed reply text is caught
by the wake/reconnect guard added in 2a937c6cc. Thinking had nothing:
its UIMessage carries no `transcriptMessageId`, and one cannot be added
— the wire event is `{type, text}` and the hydrated id comes from a
transcript uuid minted at write time, so the two sides share no id.
Every earlier fix keyed on that field, which is why five of them missed
this. `handleSdkPayload` now skips uuids it has already processed, which
covers replayed partial-message stream events too — that is what the
wall was actually made of — and does so without touching the WS protocol
or the transcript shape.
The renderer keeps a second line of defence for whole-block replays that
might arrive by some other route: a thinking chunk equal to an existing
block is dropped, blank chunks no longer open an empty bubble, and
`appendAssistantTextMessage` also rejects text identical to a hydrated
reply. Equality, not substring — a streamed delta is a fragment and is
almost always a substring of some earlier reply, so a substring test
would swallow normal output. `tool_use_complete` now flushes pending
text the way the streaming path's `content_start` already does, so the
two paths stop disagreeing about where a reply ends.
The CLI's empty `lastId` is left alone; fixing it needs the server to
answer with `x-last-request-id`. Note that only the Bun branch replays —
the Node branch calls `replayBufferedMessages` inside a check for an
upgrade header that the `ws` package removed in v3, so it never fires.
Any regression test written under Node would pass without exercising it.
Tested: bun run check:server (232 files, 2492 tests)
Tested: bun run check:desktop (275 files, 3383 tests)
Tested: bun run check:chat-contract (183 tests)
Not-tested: real sleep/wake cycle against a packaged desktop build.
The desktop card only had Submit, so a user who thinks none of the options
fit had nowhere to go. The CLI has had this exit for a while — QuestionView
renders a "{N}. Chat about this" line — but the desktop surface never got it.
Adds the same handoff as a secondary button next to Submit. Deliberately not
gated on allAnswered: not recognising your question in any of the options is
exactly when nothing is filled in. Whatever was already picked rides along so
switching to a conversation doesn't discard it.
The handoff travels as a denial because that's the only channel carrying free
text back to the model, which is the catch: buildDenyMessage wrapped every
denial in REJECT_MESSAGE's "STOP what you are doing and wait for the user".
That contradicts the whole point and would leave the user staring at a silent
turn, so AskUserQuestion joins ExitPlanMode as a denial with its own
instruction — here, to open the conversation and ask what needs clarifying.
The wording moves into constants/messages.ts and the CLI now references it too,
so the two surfaces can't drift apart.
Also fixes the status badge, which read "Answered" after a handoff and
misreported what the user did.
The strip, the active tab and the content below it were all
`--color-surface`: on the白 theme that is three planes of #FFFFFF with a
3px terracotta rule as the only separator, which is what #1123 reported as
"粗犷". The flattening came from c712f5285, which lifted the strip's ground
from `--cc-s2` to `--cc-bg` and dropped the active tab's fill.
The strip is frame, not paper. It now sits on the sidebar's ground
(`--color-surface-sidebar`, the same `--cc-s0` the sidebar already uses) and
the active tab is filled with `--color-surface`, so it reads as a sheet
lifted off the desk and continuous with the view it opens onto. No new
tokens, and the top band no longer changes colour halfway across.
The issue also proposed pill/segmented shapes. Those are segmented controls
— fixed item counts, short labels, no close/drag/overflow — so they are not
adopted here; a document tab is what this strip is. Its icon request is,
and it turned up a real bug alongside it.
Also in this change:
- Hover no longer uses `--color-surface-hover`. That token is tuned for
hovering *on* paper, so on both ink themes it lands brighter than paper
itself (dark #2B271F vs #201D17, measured luminance 0.0206 vs 0.0125) and
a hovered tab outshone the selected one. Hover now shares paper with the
active tab and selection is carried by the rule plus the label weight,
which is strictly stronger in all six themes.
- Tabs size to their titles (`min-w-[140px] max-w-[200px]`) instead of a
dead 180px, matching the workspace preview tabs. Chevron scrolling moves
by a fraction of the visible strip; pointer reordering already measured
with `getBoundingClientRect` and is unaffected.
- One fixed icon slot per tab, filling in the four kinds that had no glyph
(session, market, traces, subagent). The running dot used to be
*inserted* ahead of the label, so a title jumped sideways the moment its
session started and jumped back when it finished; the dot now swaps into
the slot and every title starts at the same x.
The `rather than a filled pill` guard is rewritten rather than deleted: the
ban is on the pill *shape* (radius, drop shadow, gaps), not on the fill, and
the comment says so — asserting `bg-transparent` is what would flatten the
strip again. Four tests added for the layering contract, the fluid width,
the icon slot's no-shift property, and the scroll step.
Verified: check:desktop green (275 files / 3369 passed / 1 skipped), and a
six-theme walkthrough over the built dist confirming visible separation in
every theme and the ink-theme hover inversion gone.
Project-scoped MCP servers written to a directory that never hosted a
session disappeared from the desktop settings list after an app restart:
the discovery set (cwd + recent projects + /api/mcp/project-paths) only
enumerated registry entries with local-scope servers, and .mcp.json files
leave no trace in the global config.
- register the target project when addMcpConfig writes a project-scoped
server, treat on-disk .mcp.json as the source of truth in project-paths,
and self-heal registry entries for pre-existing files on first browse
- stop removeMcpConfig from mutating the shared safeParseJSON cache entry:
removals poisoned every later parse of byte-identical .mcp.json content,
so a server moved between projects parsed as already deleted; add
safeParseJSONWithoutCache for callers that edit the parsed value, switch
the settings raw-update fallback to it, and make
filterInvalidPermissionRules pure for the same reason
- fetch the full known-project set when PluginDetail refreshes the MCP
store instead of overwriting the list with a one-project view
On a phone the H5 client rendered under a gray band where the status bar
sits, instead of running its own background up to the top edge the way
other mobile sites do.
iOS WebKit reads the viewport meta once, while parsing the document.
touchH5.ts rewrote it at runtime to pin the scale and carried
viewport-fit=cover along, but that rewrite cannot turn safe areas on
retroactively — so every env(safe-area-inset-*) in globals.css resolved to
0px and the browser painted its own chrome color behind the status bar.
Declaring cover in index.html is what actually takes effect.
Add a theme-color meta on the same pass. With the page now running under
the status bar, the browser chrome needs to match the palette rather than
guess at it; the pre-hydration script sets it before first paint and
applyTheme() keeps it in step, including for a palette another window
picked.
The sidebar reported "Optimizing history N/M" above the session list while
the SQLite index built. Indexing is background housekeeping the user cannot
act on, and the counter sat there for the whole build.
Drop the visible progress row and narrow the live region to `degraded` —
the one state a user can perceive, where history really is served the slow
way. Building/ready/off now stay silent for screen readers too, so the
behavior is the same regardless of how the sidebar is read.
The four locale strings this leaves unused are deleted in all five
languages, with a resurrection guard alongside the existing one for the
removed installed-skills keys.
Launching from Finder or the Dock leaves the main process with stdio that has
no reader. Writing there fails asynchronously with EPIPE from inside the stream
machinery, and with no `error` listener Node escalates it to an uncaught
exception — which Electron shows as "A JavaScript error occurred in the main
process".
This is reachable in ordinary use: the sidecar exit handler logs a line every
time a sidecar dies, so any crashed or killed sidecar could raise that dialog.
Guarding that one call site would not help, since the main process has ~25
console call sites and all of them write to the same two streams. A try/catch
at the call site cannot help either, because the throw happens off-stack.
Install the guard on stdout/stderr before anything logs. Losing a diagnostic
line is acceptable; killing the user's session over one is not — real
diagnostics already persist to a file through appendHostDiagnostic.
The trace list lives inside Settings, but opening a row jumps to a
sibling top-level tab with no return path: the detail header only
offered copy/refresh/open-window, so getting back to the list meant
finding a tab labelled "Settings" — which does not match the mental
model of someone reading a trace.
Add a "back to list" control to the detail header that returns to the
Settings trace section and closes the tab it came from, mirroring
returnFromWorkbench. It stays available in the loading and error states,
where being stranded hurts most.
Along the same path:
- Tag trace tabs with the account_tree glyph, so the title can carry the
session name instead of a truncated "Model trace: " prefix.
- Scroll the selected Settings rail entry into view. Settings remounts on
re-entry with the rail scrolled to the top, which left the selected
section highlighted off-screen after returning.
- Drop the row action that duplicated the row click.
- Fall back to a browser tab for "open in separate window" outside the
desktop shell, where it was a dead button.
Navigation is consolidated in lib/traceNavigation.ts so the list, deep
links, and the return path share one definition.
Collapsing directory, branch and worktree into one pill was supposed to end
with the location holding still: editable while the session is a draft,
read-only afterwards, same row either way. It did not. The condition read
`isHeroComposer`, and ActiveSession renders the hero variant only while the
session is empty, so the variant and the draft state flip in the same render.
The location dropped back out to a chip below the panel at exactly the moment
it was meant to stay put. The condition is the composer's width now, not its
variant.
The test written to guard this rendered `variant="hero"` against a session
with messages — a combination ActiveSession never produces — so it passed
while the shipped composer still moved the chip. It renders the default
variant now and asserts the chip sits inside the panel.
Sizing that row exposed the rest of the mismatch: the draft and the live
session were two different geometries. The draft inset its divider inside the
panel's padding; the live one welded a `-mx-4 -mb-4` band to the panel edge.
The first message therefore shifted every control 4px left and 4px down and
stretched the divider by 34px. The live row adopts the draft spacing, because
EmptySession renders the same values — two shells against one.
That alone would have grown the panel by 8px, but the live textarea was also
paying for a descender gap: a textarea is inline-block, and the hero branch
escapes it only by sitting in a flex row. `block` recovers 6px, so the panel
ends up 2px taller with four alignment defects gone.
The narrow layouts keep the band. `p-3` has no padding to spend on inset, and
they never swap variants mid-session, so there is nothing there to hold still.
`Verify Windows installer execution` failed twice in a row on the v0.5.0
tag, both times at the same assertion:
Elevated default-mode reinstall without CLR expected process exit
code 20, received 22.
20 and 22 are different answers to "why did setup stop". 20 is legacy
recovery refusing to continue; 22 is "a matching process is running". The
stage breaks the CLR on purpose so the installer cannot run PowerShell,
which is exactly when CcHahaFindInstallProcess degrades from resolving
paths to matching bare image names -- `Claude Code Haha.exe`, the three
`claude-sidecar*` names, `OpenConsole.exe`, `winpty-agent.exe`, `rg.exe`.
Any process on the runner carrying one of those names answers for the
stage, whoever started it.
The stage before it expects 22, so a stray match there is indistinguishable
from a pass; the stage after it expects 20, so the same stray match is a
failure. That asymmetry is why this reads as "one flaky assertion" rather
than "the whole no-CLR group is unguarded".
This does not fix the installer, because the installer is not wrong:
refusing to delete user data when it cannot confirm what is running is the
intended fail-closed behaviour, and neither installer.nsh nor this script
changed between v0.4.11 (green) and v0.5.0 (red). What changed is what was
running on the runner. So the script now controls that instead of assuming
it:
- Both stages that expect 20 first clear any process matching the same
name list. `WaitForExit` only covers the PIDs this script started; the
fallback matches names, so it also sees leftovers from earlier steps of
this job -- the compiled-sidecar smoke starts 20 sidecars -- and any
child a probe spawned.
- Clearing warns instead of throwing when something survives. A survivor
is runner-owned and out of reach, and failing there would replace the
stage's own failure with a less informative one.
- A mismatched exit code now prints every matching process with PID and
path, and a baseline is printed before the first install. Between those
two, a future failure says whether the runner was dirty or the installer
regressed, which this run had no way to answer.
The name list is duplicated from installer.nsh by necessity -- NSIS
compile-time state is not readable from PowerShell -- and is commented on
both sides to be kept in sync.
Not verified locally: this needs Windows and an ephemeral runner (the
script refuses to run unless CI=true, since it mutates installer registry
state). Reviewed for Windows PowerShell 5.1, which is what the workflow
invokes: no pwsh-only syntax, and the file is kept pure ASCII as it was,
so 5.1 cannot mis-decode it.
The same suite passed under `check:desktop` and failed under
`check:coverage` — 271 files and 3332 tests either way, with one case red
in the second: "returns null rather than throwing on a missing or corrupt
cache" read back `{isDark: true, background: '#201D17', ...}`, which is
exactly what the case above it writes.
`appearanceStatePath` resolves `env.CLAUDE_CONFIG_DIR` before falling back
to `app.getPath('home')`, and these cases passed no env, so they defaulted
to `process.env`. `check:coverage` runs its suites through
`createSandboxedTestEnvironment`, which sets CLAUDE_CONFIG_DIR
(scripts/pr/test-environment.ts:74). With it set, the per-case temp
directories from `makeApp()` stop deciding anything: every read and write
collapses onto one shared file, and `afterEach` only removes the temp
directories, never that file. So the round-trip case wrote it and the
missing-cache case read it. Only that one case is ordered to notice — the
others write before they read.
Nothing about the product is wrong here: defaulting to `process.env` is
what the shipped code should do, and a portable install setting
CLAUDE_CONFIG_DIR is a supported mode. The defect is that the tests never
opted out of it.
Each case now passes the isolated env `makeApp()` hands back, which is the
convention `windows.test.ts` already follows for the same path shape (it
threads `{}` or `{CLAUDE_CONFIG_DIR: tmp}` through every call). Verified
both ways: with CLAUDE_CONFIG_DIR set — the condition that reproduced the
failure — and without it, 23/23 each time. `check:coverage` now reports
5/5 suites, and `check:desktop` stays green.
`sidecarManager.ts` and `windows.ts` resolve paths the same way; their
suites were checked and already pass an explicit env.
The header sat at 12px while everything below it started at 24px — the
new-session, scheduled and market icons, the search glyph, the settings
gear. The name hung out on a line of its own to the left, so pad it onto
theirs.
That spends 12px of header room, and the long form was already running
out around 244px. Rather than clip it mid-letter, carry a short form as
well and swap on a container query over the title region: no sidebar
width now shows a cut-off name.
Importing an animated pet required a file that was exactly 1536x2288, laid
out as 88 seamless cells, with the last two rows holding sixteen distinct
gaze angles. No image model emits that. Whatever a user got back from Jimeng
or ChatGPT was some fixed size like 1024x1536, so the path ended at "the
animation atlas must be exactly 1536x2288 pixels" every time. The third card
was worse: "AI-generate full animation" was hardcoded `disabled`, so the one
entry point named after what people actually wanted to do was dead.
The fix was already in the tree. `scripts/assemble-generated-pet-atlas.py`
landed in the same commit as the four built-in pets, which is to say the
built-ins were produced this way — it takes an action sheet at any size,
slices it on an 8x9 grid, fits each cell to 192x208, mirrors the run row to
make run-left, and reuses rows to reach eleven. That capability was never
wired to anything a user could reach.
`petAtlasNormalize.ts` reimplements it on a canvas in the renderer, so an
author draws nine rows and the app derives the rest. Verified against the
reference assembler by reversing dada-code's atlas into a nine-row sheet and
re-normalizing it: every difference lands on semi-transparent antialiased
edges (2314 pixels, max channel delta 14/255) and opaque regions are
identical. That residue is canvas premultiplied-alpha round-tripping, not a
slicing bug.
Three contract details worth stating. Row frame counts are now derived from
`PET_ANIMATION_DEFINITIONS` rather than typed out a fourth time; they come
out equal to the assembler's `(6,8,8,4,5,8,6,6,6,8,8)`. A sheet already at
1536x2288 passes through byte-for-byte instead of being resliced, because
resampling finished artwork buys nothing. And since the validator never
inspects the alpha channel, a flattened white background used to import
happily and render as a rectangle on the desktop — the renderer now rejects
sheets whose atlas is under 5% transparent (the built-ins sit near 78%) with
a message that names the actual problem.
The copy stops describing the implementation. "Animate one image" and
"Import professional animation atlas / exact 1536x2288 v2 PNG" become "use a
picture you already have" and "I already have an action sheet"; the dead AI
card becomes a three-step walkthrough carrying a copyable prompt, a labelled
8x9 reference grid that can be saved locally, and the checks that catch the
common failures. Reference images are generated by a script rather than hand-
placed, in both languages. All five locales move together.
Caught while reviewing the real dialog in Electron: after finishing the
walkthrough the form heading fell through to the atlas branch and announced
"I already have an action sheet" to someone who had just been walked through
drawing one. Covered by a test now.
Not done: docs/images/desktop_ui/15_pet_create_methods.png still shows the
old dialog and needs a fresh capture from a running app to match the styling
of the shots around it.
The provider list behind the 860px "add provider" dialog was legible
straight through the panel — URLs and model names readable in the form's
empty space.
`.glass-panel` states a translucent fill and a blur in one rule, and reads
as frosted only when both land. The blur is the fragile half: where
`backdrop-filter` does not run there is no failure for CSS to report. The
declaration is skipped, the 0.84 fill is left standing on its own, and 16%
of the page comes through unscrambled. A reduced repro pins it — with the
blur live nothing inside the panel is readable; with it disabled the result
matches the report exactly.
Dialogs leave the coupling entirely. `--color-surface-dialog` is opaque by
construction (no alpha channel to walk back one decimal at a time), mapping
to `--cc-bg` on light themes and `--cc-s1` on the ink ones — ink runs its
background at the bottom of the ramp, so a lifted surface has to climb it
rather than reuse it. `.dialog-panel` carries fill, hairline and shadow and
never touches `backdrop-filter`.
Two things follow from an opaque panel. The scrim is now the only thing
separating the dialog from the page, so `Modal` moves to the heavier
`--color-modal-scrim` — the token that already existed for exactly this and
had only `GlobalSearchModal` as a user, while `Modal` sat on the non-modal
one. And the `:focus-within` ring goes: a dialog holds focus essentially
always, so it burned permanently rather than signalling anything.
The small floating layers keep the glass, but no longer depend on the blur
for legibility: the fill goes to 0.92/0.93. The `@supports` fallback added
alongside it is worth less than it looks — it catches engines that do not
implement `backdrop-filter`, not the failure seen here, where the query
returns true and the blur still never runs. Raising the density is the half
that actually covers the reported case.
Why the blur is inert on this machine is not established. Ruled out on the
code side: GPU switches, containment on `html`/`body`/`#root`, and CSS
`zoom` (UI scaling goes through Electron's `setZoomFactor`). The fix does
not depend on that answer.
Verified across warm-classic, dark and ink-blue: computed fills come back
as `rgb(...)` with no alpha, `backdrop-filter: none`, and both ink themes
render the panel lighter than the page behind it.
Directory, branch and worktree were three separate buttons on a bar welded
under the composer. That bar forced the panel's squared bottom edge, so the
composer read as three stacked bands split by two divider lines, and the whole
row jumped outside the panel as a read-only chip the moment the first message
was sent.
They are one pill now, sized for the toolbar row it shares with "+" and the
model selector. The panel is fully rounded again and keeps a single divider.
The pill stays put for the life of the session: editable while the session is
a draft, read-only afterwards, same row either way.
Directory, branch and both worktree modes live in the pill's menu. The worktree
modes are one click from the root view; the branch list is a second view with
its own search and a way back. The nested directory picker portals its dropdown
to the body, so the menu exempts it from its own outside-click handling.
A truncated branch keeps its tail — `…use-native-on-main`, not `feature/comp…`
— because the end is what distinguishes it. `dir="rtl"` moves the ellipsis to
the front and `<bdi>` stops the RTL container from reordering the slashes.
H5 already took a different path here (`useCompactControls` keeps the controls
outside the panel), so the pill lands on its own line there at 40px for touch,
and uses the existing bottom sheet. That line is now a fixed single row: the
three buttons needed 447px against 338px available and wrapped to two, with the
row count following the branch name.
Sizing the pill exposed a layout bug: a long branch grew it until the
permission selector wrapped to two lines and the toolbar grew with it. Shrink
now falls on the pill alone.
The English label is "Location", not "Run location", which would have collided
with the Run button for anyone reading the row through a screen reader.
The Windows x64 build job went red on `Verify compiled Windows sidecar
startup`, asserting that a loopback request without
`CC_HAHA_LOCAL_ACCESS_TOKEN` returns 403 while it returned 200. Nothing
about x64 is involved — that step carries
`if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'`, so it is
the only job in the whole matrix that runs the smoke at all. Any regression
in this area can surface nowhere else.
The 200 is correct. `7d2a8a3cd keep loopback trusted without the desktop
process token` deliberately made the token additive again: gating every
local request behind it turned the Grok OAuth success page, `/preview-fs`
links and plain `curl` into 401s, because none of that traffic can ever
carry the token. Loopback is trusted on its own; the token is demanded only
on the `/api/h5-access` control plane, where another program on the same
box must not be able to publish the user's sessions to the network. The
assertion, written before that change, was still guarding the path that had
been intentionally opened.
So the probe moves to the boundary that is actually enforced, and gains a
positive assertion — loopback without a token must be 200 — so the additive
model is pinned down rather than merely no longer contradicted. Reverting to
the pre-`7d2a8a3cd` behaviour now fails the smoke instead of passing it.
Three copies of the stale assertion existed; all three are updated. Only the
compiled-sidecar smoke runs in CI, but `local-index-benchmark.ts` and its
corpus test were already failing the same way for anyone running them
locally. The benchmark also cancels the probe response bodies now: an unread
body holds its connection open, and that would land in the event-loop delay
and RSS samples taken immediately after.
Verified with the CI parameters — `bun run build:sidecars` then
`CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS=20 bun run test:compiled-sidecar-smoke`,
8/8 — and by running the benchmark directly, which now reports
`loopbackAuth` as 200/403/403/200 with validation intact.
Three things about the left column, all reported from the same screenshot.
The brand mark beside the wordmark was clutter. Expanded, the sidebar already
says "Claude Code Haha" in the headline face, and the 32px seal next to it
repeats what the words carry. Removing it outright emptied the header on the
72px rail, though — the copy there is width-clamped to zero by
`.sidebar-copy--hidden`, so nothing was left to identify the app. The mark now
renders only when collapsed, at `sm`: two C's and the seal bar, without the
cursor arrow that reads as a stray orange wedge at that size. BrandSeal already
sheds parts as it shrinks, so this is the size ladder doing its job rather than
a new variant.
The settings rail was 260px for a column whose longest label is two words. It
is now 220px. 200px was the first choice and it was wrong: measured in a real
browser against the live stylesheet, the Japanese "コンピューター操作" needs
122px of text box and 200px leaves 115px, so it truncated. The threshold sits
at 210px; 220px keeps ten pixels of headroom over the worst locale.
The sidebar itself is now resizable. An 8px handle on its right edge drives the
width between 240 and 480px, persisted to localStorage, with a double click
back to 300 and arrow-key steps for the keyboard. Dragging left past 240 pins
there until the pointer crosses 180, which collapses to the rail; coming back
out past 200 re-opens it. The 20px gap between those two thresholds is
hysteresis — with one boundary the sidebar flickers open and closed on any
tremor of the hand. A drag that ends in a collapse deliberately does not commit
its width, so re-opening from the toggle restores the size the user chose
rather than whatever value the pointer happened to sweep through.
The live width travels as a CSS variable written imperatively onto the shell,
never through React state. Sidebar re-renders during a streaming turn would
otherwise land between drag frames and fight the pointer, which is the failure
that made the pet animation stutter. The store is written once, when the drag
settles.
That variable exposed a bug the unit tests could not see. `#sidebar-shell` sits
behind AppShell's startup gate, so it mounts a render later than the hook. With
a plain object ref, `shellRef.current` is still null on the single pass the
width effect ever runs, and because neither dependency changes afterwards the
effect never fires again — the remembered width never reaches the DOM and every
launch silently falls back to the stylesheet's 300px. Only visible by loading
the real app: the harness in a test mounts the shell and the hook together.
Fixed with a callback ref, and `useSidebarResize.test.tsx` now models the gate;
that case was confirmed to fail against the object-ref version.
Verified in a browser against the dev server: drag 300→420 persists as "420",
crossing 150 settles the shell at the 72px rail while localStorage still holds
the chosen width, and the truncation thresholds above were measured by
substituting each locale's longest label into a live tab.
Two reviews of the icon swap, run independently from opposite directions,
turned up three places the new mark never reached.
The og:image was the worst. `site/index.html:20` points at
`/images/banner.png`, and that file does not come from `docs/images/banner.png`
— the one this rebrand replaced. `site/scripts/prepare-static-output.mjs:129`
copies `docs/public/` wholesale into the site root before the two selective
image passes run, and neither of those matches an absolute https:// URL, so
`docs/public/images/banner.png` is what shipped. It was a 1200x630 screenshot
of the old site: blue panels, old circular CC badge. Every link shared to
WeChat or Slack would have previewed the pre-rebrand brand while the site
itself rendered the new one. Replaced with a card built from the new lockup;
`site/dist/images/banner.png` now hashes to it.
`desktop/src-tauri/app-icon.png` is the canonical 1024 RGBA source the platform
icon set gets regenerated from. That rule lived only in the body of 3f2ce2a6c,
and nothing references the file in code, so the rebrand skipped it — breaking
an invariant that had held since the file was introduced, where it and
`desktop/public/app-icon.png` were the same git blob. Left as it was, whoever
regenerated icons next would have restored the entire old set.
Linux had no icon above 310px. electron-builder points `linux.icon` at the
whole icons directory and keeps only files named NxN, so `icon.png` (512, no
dimensions in the name) and `128x128@2x.png` (256, collides with
`128x128.png`) were both dropped, leaving a Windows Store asset as the largest
entry. Adding 256x256.png and 512x512.png takes the resolved set from 12
entries topping out at 310 to 14 topping out at 512, confirmed by running
app-builder's icon resolver against the directory.
index.html also had no favicon, and that document is what the H5 remote client
loads in a phone browser.
CI could not have caught any of this — `scripts/quality-gate/package-smoke/`
asserts nothing about icons. `desktop/icon-assets.test.ts` now pins the source
invariant, the Linux sizes, the three packaged icons and the favicon; each
assertion was checked to fail when its subject is reverted.
`app-icon.png` is a 120KB bitmap, and the seven `.svg` files beside it under
docs/images are 193 bytes each — an `<image>` tag wrapping that bitmap. So the
mark carried its own blue/cyan/orange through all six palettes while everything
around it moved, and there was nothing to recolor.
Measured the bitmap back into geometry rather than redrawing it: connected
components to separate the elements, algebraic circle fits for the arcs, angular
histograms for the openings and stroke widths. Big C is center (415,566) r=131
stroke=60, opening 160°; the second C is center (615,576) r=117 cut into two 68°
arcs; the cursor is a four-point polygon with one notch. The slight ellipticity
and unequal stroke ends were raster artifacts, so they were rounded out. The
shape is otherwise unchanged — differencing a render against the original leaves
only antialiasing.
Color maps the original's three layers onto the palette's own three: the C's take
墨 `--cc-t1`, the bar and cursor and sparkles take 朱 `--cc-ac`.
Six themes do not need six icons. Their accent resolves to two values — #96442B
across the four light palettes, #D07B52 across the two dark ones, which is the
same ochre lightened. Only the ink varies, and ink should follow body text
anyway.
The app icon stays one fixed artwork. Dock and taskbar are drawn by the OS, which
knows nothing about the in-app theme, so paper ground with ink C's ships to every
platform. Inside the app the mark is a vector on tokens and repaints per theme.
Four ideas — two C's, the bar, the cursor, two sparkles — collapse below 24px, so
every consumer sheds parts as it shrinks: sparkles above 40px, cursor above 24px,
C's and bar always. That applies inside icon.icns and icon.ico as much as in
BrandSeal.
BrandSeal was the 「哈」 glyph in a terracotta square; it is now the CC mark drawn
inline on `--color-text-primary` and `--color-brand`. Its five call sites — the
sidebar, both empty states, the H5 connect view, the gallery — follow, and the
about pane drops its `<img>` for the same component.
README leads with the horizontal lockup inside a `<picture>` so GitHub serves the
dark cut in dark mode.
Three things the catalogue got wrong once the window was larger than a
laptop lid.
The first-page skeleton was a fixed six cards. On a wide desktop shell
that is two rows above half a screen of nothing, which reads as "loaded,
almost empty" for as long as the request takes. It now measures the space
below itself and fills it, capped at a page so it never promises more
cards than can arrive.
The next page came from a button. It now comes from an observer on a
sentinel, started 400px early, with a row of placeholder cards while the
page is in flight. The observer is rebuilt after each page: it only
reports changes, so a sentinel that never left the viewport would fire
once and leave a tall window half filled. The button stays as the
fallback for a runtime without IntersectionObserver.
A failed page used to land in `error`, which blanks the catalogue behind
a full-region panel — and under auto-loading would have walked straight
back into the same failure forever. It gets its own `loadMoreError`: an
inline notice under the grid, a retry the reader asks for, and no
observer until they do.
Also adds the way out of the catalogue: the header now carries an entry
to the installed-skills browser in Settings, next to the source status.