mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
1025 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
05085b83bc |
fix(mcp): keep project-scoped servers visible across restarts (#1126)
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 |
||
|
|
b33c8dbce2 |
fix(desktop): ship viewport-fit=cover in the H5 markup
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. |
||
|
|
617a0a334d |
fix(desktop): index session history without narrating it
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. |
||
|
|
ec5094fb57 |
fix(desktop): stop a failed log write from crashing the main process
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. |
||
|
|
cfed362e89 |
fix(desktop): give trace detail tabs a way back to the list
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. |
||
|
|
128eb77e08 |
fix(desktop): keep the run location in the composer toolbar for the whole session
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. |
||
|
|
730e21f717 |
fix(test): stop attributing stray processes to the no-CLR installer stages
`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.
|
||
|
|
c64f69972d | release: v0.5.0 | ||
|
|
137895f23e |
fix(test): isolate the appearance cache from a real CLAUDE_CONFIG_DIR
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.
|
||
|
|
fc8ed80068 |
fix(desktop): line the sidebar wordmark up with the nav icons
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. |
||
|
|
fc9f5d554e |
feat(desktop): let a nine-row action sheet become a pet instead of demanding an exact atlas
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. |
||
|
|
e972a362dc | Merge branch 'fix/dialog-opaque-fill' | ||
|
|
e43a70f9a1 |
fix(desktop): give dialogs an opaque fill instead of betting on the blur
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. |
||
|
|
ff20817e3a |
feat(desktop): collapse the run location into one pill in the composer toolbar
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. |
||
|
|
9b88c5a527 |
fix(test): probe the surface the desktop process token actually guards
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. |
||
|
|
8eba29bdd4 |
feat(desktop): put the sidebar width under the user's control and trim the nav chrome
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. |
||
|
|
49f1974007 |
fix(brand): close three icon gaps the first pass missed
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. |
||
|
|
19357fcd87 |
feat(brand): rebuild the app mark as a vector and recolor it to the themes
`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. |
||
|
|
7c908176f5 |
feat(desktop): scroll the skills market instead of pressing load more
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. |
||
|
|
09120df85a |
fix(stats): count one assistant reply once instead of once per content block
Claude Code writes an assistant message as one JSONL line per content block — thinking, text, and each tool_use separately — and every one of those lines repeats the same complete `usage` object. Both stats paths summed them, so a reply cost as many times as it had blocks. One real message in this repo's own transcripts spans 52 lines carrying 50.8K tokens, and was counted as 2.65M. Scanning ~/.claude/projects with 1927 sources: the total drops from 9.76B to 4.59B tokens, and the busiest day from 2.60B to 1.46B. 39,290 of 69,306 usage lines were repeats. Usage records now deduplicate on (message.id, requestId), scoped per transcript, and survive an incremental read. Lines with no message id are still counted, matching ccusage. Two paths compute these stats — the local index reducer and the direct scan in stats.ts — and a parity test pins them to identical output. Both carried the bug, so the rules that decide what counts now live in one module, `utils/usageAccounting.ts`, rather than being written twice and drifting. Also fixed, all surfaced while verifying the above: Cost was dead code. `costUSD` and `webSearchRequests` were initialized to 0 in the reducer and never assigned, so 0 travelled to SQLite and out again. Now estimated from the rates in modelCost.ts — but with an unknown model returning null instead of falling back to the default model's rates, because 12.7% of the tokens here come from third-party providers (k3, glm, MiniMax, deepseek, grok) that would otherwise be billed at Claude prices. Those models keep their tokens in the activity totals and are named in `unpricedModels` so the UI can say the dollar figure is a floor. `MODEL_COSTS` has no entry for claude-opus-5 — the canonical-name resolver maps it to `claude-opus`, which isn't a key either — so the largest model by usage priced through the unknown-model fallback. The new module handles it explicitly. The CLI's own calculateUSDCost still takes that fallback; left alone here, it reaches well beyond activity stats. "Longest task" measured `last - first`, a calendar span rather than a duration: a session resumed the next morning reported the whole night as time on task. The panel read 436 hours for a 17-message session, and the worst case on this machine was 1137 hours. Sessions now accumulate working time across gaps under 30 minutes, stored in a new `active_duration_ms` column. Workflow subagent transcripts were never indexed. Discovery read one level of `subagents/`, but workflow agents nest a group deeper at `subagents/workflows/<id>/`; 79 files here were invisible to both the index and the direct scan, which additionally attributed them to a session named "workflows". Ties in "longest session" now break on session id. The two paths iterate sessions in different orders, and ties became reachable once an out-of-order session scored 0 rather than a distinct negative span. The parser version bump is what makes any of this reach an existing install: `detectSourceChange` rebuilds a source only when its stored version differs. |
||
|
|
eaf1a96a3e |
fix(desktop): move the session metadata back under the title
Pinning the metadata to the right end of the title row left a wide gap between them, so the two read as unrelated blocks rather than one header. Put it back on its own line under the title at 11px, and give the header back some of the height it lost: a 17px title (15px in the workspace layout) and py-3. Measured 66.75px on a real session, between the 123.5px original and the 35.75px single-line version. The title still truncates to one line with the full text in a hover tooltip, so a long title cannot push the transcript down again. |
||
|
|
5b891151ae |
feat(desktop): follow the system dark/light appearance (#1106)
An Auto-dark-mode user reported being flashed by a white window every evening, then switching to a dark palette by hand. That is two defects, and only one of them is the missing feature. The flash fired even for someone who had already saved a dark palette. `index.html` hardcoded `data-theme="white"` while the code that reads the stored theme, `initializeTheme()`, only runs after the app bundle's dynamic imports resolve. So every launch painted white first. A synchronous inline script now resolves the theme before any stylesheet is parsed, and Electron seeds `backgroundColor` from a cached appearance so the window is not white before the renderer's first frame either. Following the system is a switch in Settings -> General rather than a seventh palette. The OS only reports dark/light while the app ships six palettes, so each ground carries its own preference: the picker splits into "use in light mode" (the four paper grounds) and "use in dark mode" (the two ink ones), and a pick lands in the preference for its own ground. Choosing ink-blue at noon is therefore remembered for that night rather than fighting the OS. Detection goes through `prefers-color-scheme` because the same renderer runs under Electron, the Tauri shell and the browser entry, and the media query is the only signal all three share. New installs follow the system; existing ones keep their fixed palette until they opt in, so an update never silently repaints someone's app. `nativeTheme.themeSource` is deliberately left alone, which is the part most likely to be "fixed" later. Pinning it to the user's palette would make context menus and the macOS frame agree with the app, but it is a process-wide override of `prefers-color-scheme` — the very signal this feature reads. Re-enabling the switch would then resolve against the pinned value instead of the real OS setting, and the override also leaks into the preview WebContentsView, forcing third-party pages to the app's theme. A test fails on any assignment to it. The OS-flip listener reads the preferences from storage rather than from its own store. The pet and trace windows run the same bootstrap with their own store instance over one shared localStorage, so after the main window turns the switch off their in-memory copy still says "on" — acting on it wrote the user's choice straight back out. A `storage` listener catches the other windows up. `settingsStore.theme` is gone. It was a copy that only refreshed on an explicit `setTheme`, so an OS flip left the Settings picker highlighting a palette that was no longer on screen. uiStore owns the theme; the copy had no remaining readers. The 「纸·墨·印」 rename reaches the new keys too: `light` -> `warm-classic` now migrates for the per-ground preferences, not just the applied theme, so the palette daytime returns to is not silently reset. Guards, each verified by breaking what it protects: the inline script is extracted from `index.html` and run verbatim against `resolveAppliedTheme` over every stored combination — including dirty values, which are reachable because it runs before the persistence migrations, and cross-ground values like a dark palette stored as the light preference; the three copies of the palette grounds (CSS `--cc-bg`, `index.html`, main process) are pinned to each other and to `THEME_MODES`, so a seventh palette cannot ship without a pre-paint color; the two grounds are proven to cover every palette at compile time; and the IPC payload is held to a literal 6-digit hex because `setBackgroundColor` also accepts `#AARRGGBB`, where a translucent window means click-through and overlay spoofing. |
||
|
|
5a4ba1a257 |
fix(desktop): collapse the session header to a single line
The redesigned chat header stacked a non-truncating 22px title above its metadata row inside px-9 pt-6 pb-4, so a long session title wrapped onto two lines and pushed the transcript down. Truncate the title on one line at 15px, move the metadata onto that same row, and tighten the padding to py-2: measured 123.5px -> 35.75px on a real session at a 1600px viewport, with the full title kept in a hover tooltip. The wide and workspace-open layouts now share one set of styles instead of drifting apart, and the metadata separator is marked aria-hidden since it is pure decoration. |
||
|
|
6c7bc27e9a |
fix(desktop): drop tasks deleted by TaskUpdate from the activity panel (#1101)
`TaskUpdate` treats `deleted` as a delete action, not a status — the CLI
tool unlinks the task file and returns early. The activity panel read it as
a status, and `normalizeTaskStatus` has no case for it, so it fell through
to `pending`. The row then survived `mergeTaskRowsById`, which only
overwrites rows the live list still knows about. A task the server had
already deleted stayed pinned to the panel as pending, badge included.
Reproduced end to end against a real provider (MiniMax-M3) on a throwaway
Node project: the model reached for `TaskUpdate{taskId:'1',status:'deleted'}`
on its own, `~/.claude/tasks/<session>/1.json` was gone afterwards, and
`GET /api/tasks/lists/:id` returned only the two survivors — while the panel
still rendered three rows with a badge of 1.
Three places needed it:
- Parsing `TaskUpdate` now removes the row instead of restating it. This
also stops the panel inventing a `Task #N` row when the deletion has no
matching `TaskCreate` in the same turn.
- Deletions are collected across turns. A task created in one turn is often
deleted in a later one, and per-turn parsing left the earlier row behind,
which also skewed the "Earlier tasks" roll-up to `1 of 2 / stopped`.
- `liveTasks` is filtered too. The task list only refreshes once the
`tool_result` lands, so between the deletion and that round trip it still
reports the deleted task.
`normalizeTaskStatus` keeps its `pending` fallback — `deleted` is now caught
upstream, and folding a delete action into the status enum is what caused
this in the first place.
Verified by replaying both real transcripts through the old and the new
model side by side: the single-turn session goes from 3 rows/badge 1 to
2 rows/badge 0, and the two-turn session from `Task #1 pending` plus a
`stopped` history row to just the one completed task. Four regression tests
cover same-turn deletion, deletion without a matching create, cross-turn
deletion, and a stale live list. `check:desktop` green at 3051 tests.
|
||
|
|
efb8b5c9b4 |
fix(desktop): keep a streamed thinking block whole while a background agent works (#1108)
One continuous thinking block arrived in the chat split across several `已思考` bubbles, with nothing rendered between them. The report blamed the model — DeepSeek — but the provider is not involved: the trace holds a single well-formed thinking block, and reopening the session restores it, because history is rebuilt from the transcript's own blocks. The split comes from three pieces that are each correct alone. A background (async) agent's tool activity is re-emitted as a normal `tool_use_complete` / `tool_result` carrying `parentToolUseId` (`handler.ts`, added in 1c554dc30 so background subagents stop showing "no tool activity"). Those land at the end of `messages`. `MessageList` then folds them into the parent agent card rather than rendering them inline. But the merge test for a streamed thinking chunk asked whether the *array tail* was thinking — and the tail was now a child tool call, so every chunk after one started a new bubble. The split points are the moments background activity arrived, which is why they look arbitrary and why the gaps between bubbles are empty. `findStreamMergeTargetIndex` skips those bubbled-child messages and merges against the last real main-stream message instead. The same tail test backed `appendAssistantTextMessage`, so a reply could be chopped the same way; both now share the helper. Reproducing needs thinking mode, a `run_in_background` agent, and child activity landing mid-thought — rare enough that most sessions never see it, and near-certain for a slow reasoning model made to wait on agents. The screenshot shows exactly that: four subagents running, and the mangled thought reads "The agents are still running. Let me wait a bit." Covered by three tests: thinking stays one block across interleaved child activity, a reply stays one block, and the main agent's own tool call still opens a new block. Verified against the reproduction before and after. Desktop suite 3050 tests green, tsc and build clean. (cherry picked from commit 7d8a22f9465b8f987193dda7af5cae3858c481fc) |
||
|
|
c712f52858 |
feat(desktop): 「纸·墨·印」全量 UI 重设计
按设计交付稿重构桌面端整体视觉,只换表现层,功能、数据契约与 交互逻辑零改动。 主题与地基: - 6 套主题(纯白/纸墨/经典暖色/青瓷/墨夜/墨夜蓝),源色块 + 单一 语义映射层两层结构;老设置 light→warm-classic 自动迁移 - 状态色全部成对并过 AA;新增 --color-on-brand-soft、 --color-search-highlight 对、--color-border-strong(1.4.11 控件边界) - 衬线标题自托管 Noto Serif SC 拉丁分片,中文回退系统衬线 - 圆角/阴影/动效阶梯按稿定稿;新组件 BrandSeal 印章 屏幕:外壳(52px 标签条陶土下划线、印章侧栏、选中会话浮起卡)、 零态、活跃会话、composer 全部弹层、技能市场、定时任务、设置全 分区、Trace、终端、⌘K,及设计稿未覆盖界面的统一收敛。 Review 修复(两轮 6 主题全量走查 + 盲区补扫挖出): - 未分层 CSS 压死 utilities 两处(图标 24px、focus ring 被吃) - font-[var(--font-mono)] 无效写法 35 处(mono 从未生效) - tertiary 文字与控件边框对比度全线达标化,contrast 守门扩容 - /N alpha 修饰符清零(Safari 15 WebView 整条丢弃) - 触屏 44px 主目标口径统一;hover 门控控件 H5 常显 - 错误态修复:定时任务静默失败、Trace/会话列表错误呈现 - 宠物窗口 ink-blue 暗色覆盖补齐 测试:264 文件 / 3162 用例全绿(新增六主题对比度、跨文件弹层 一致性、触摸目标、错误态等守门);tsc 零报错;构建通过。 |
||
|
|
47d47d1881 |
test(desktop): automate the QA items left to manual verification
Independent QA marked three checks `not run` because they needed a human at the keyboard. Each covers behavior this refactor changed, so they are now assertions instead of intentions. `A11Y-01` — a full keyboard tab-order audit. These components back roughly 500 controls, so a decoration that picks up a tab stop or a control that loses one would spread everywhere before anyone noticed. `keyboard.test.tsx` fixes the property they must all share: interactive components expose an exact number of tab stops, decorative ones expose none, `disabled` and `loading` leave the sequence, a segmented control stays one stop at any size, and every reachable control shows a focus indicator. That last check has two valid shapes, which the first version got wrong: most controls style themselves, but `Switch` hides its native checkbox under `peer sr-only` and paints the ring on the track beside it. Checking only the focused node reported a missing indicator on a control that draws one. `ATT-02` — the image lightbox. Its arrows were icon-only with hardcoded English before this branch; the existing suite only counted overlay registrations. Now covers naming, wrap-around in both directions, arrow keys, that the arrows disappear for a single image, and that keys stop firing once closed. `SEARCH-02` — stepping through find-in-page matches. `FindInPageModal` had no `useTranslation` at all; its three controls are icon-only, so a missing name leaves them unreachable by screen reader. Now covers naming, the disabled state before a query matches, forward and backward wrap, and close. `CHAT-05` (queued message edit/delete) needed nothing — `ChatInput.test.tsx` already covers it; QA had only skipped it by hand. Each guard was verified by breaking the thing it protects: a `tabIndex` on `Badge` fails the decorative check, and reverting the focus fix fails the indicator check. Changed-lines coverage 91.32%. |
||
|
|
598b968eec |
test(desktop): cover the components left at zero and exclude dev tooling
Independent QA on 3264db10 flagged changed-lines coverage at 86.17% (5539/6428), under the 90% gate. Two causes, handled differently. `desktop/src/dev/` joins `mocks/` and `types/` in the coverage exclusions. It holds the component gallery — 260 of the 889 uncovered lines, and by far the largest single contributor. Vite never bundles it (the build input is `index.html` alone), and unit-testing a page whose whole job is rendering every primitive would assert that the primitives render, which their own tests already do. Excluding it is a scope correction, not a threshold adjustment. The rest are three components this branch touched that had no test file at all. They now have one each, covering the behavior that changed: - `BackgroundTasksBar` — drawer open/close including Escape, the running vs finished split, dismissed-key filtering, and that clearing reports every finished key while keeping the drawer open if work continues. - `TeamStatusBar` — the progress bar's `aria-valuenow`, lead exclusion from both list and count, and that it greens on "nothing running" rather than on 100%: one completed plus one errored is done at 50%, which is why `tone="auto"` would have been wrong here. - `MarketSkillDetail` — skeleton semantics, retry, install/uninstall by `installState`, and the disabled+spinner state mid-install. Changed-lines coverage: 91.06% (5610/6161). The QA report's second finding, `check:impact` blocking on a missing `allow-cli-core-change` label, is an artifact of the branch trailing main. `check:impact` diffs against `main`, so main's own newer commits — 9 files under `src/` — are counted as this branch's. Against the merge base the same evaluator returns `areas: desktop, blocked: false`. No code change here; the branch needs a rebase before it can pass that lane. |
||
|
|
f91b62f485 |
docs(desktop): document the component library and its rules
`components/AGENTS.md` is the reference: what to use for a given need,
where a new component goes, and the style / i18n / a11y / test rules.
`desktop/AGENTS.md` now routes here — the line it replaces ("reuse the
existing desktop design system") named nothing to look up and so was not
an executable instruction for a person or a model.
`docs/component-library-plan.md` keeps the audit evidence and a record of
what actually shipped, including where the plan was wrong.
Two rules earned their own sections because the library broke them
itself:
- Overriding a component's utility with `className` does not reliably
win. Tailwind sorts same-utility arbitrary values by value and takes
the last, regardless of the order they were passed. `hoverTone="danger"`
was a no-op with exactly the two tones it was built for, and its test
passed because it only asserted the red class was *present*, never that
the neutral one was gone. Assert that a class prefix appears exactly
once, and prove it by reverting the fix.
- The library must not compose user-visible English. `SearchField` built
`Clear ${label}`, which made adopting it an i18n regression for every
caller that had already translated its clear button. The first repair —
falling back to `label` — was worse: the input and its clear button
then shared an accessible name and `getByLabelText` matched both.
Reuse went from 22% to 75% (524 component uses against 179 remaining
native buttons). The remainder are elements that should not be
components: `role="tab"`, `menuitem`, `option`, `treeitem`, `gridcell`,
whole-row and whole-card click targets, drag handles, and the OS
titlebar.
|
||
|
|
9dd7295cd2 |
feat(desktop): add a dev-only component gallery
Renders every `components/ui` primitive under each of the three themes at `/gallery.html`, reachable under `bun run dev` with no backend. Unit tests assert structure and ARIA. They cannot tell whether a token resolves to a readable color, whether an overlay lands above the thing it is meant to cover, or whether an entrance animation actually plays. Three defects surfaced here that had a full green suite behind them: - `Button` and `IconButton` swallowed refs, so `Tooltip` had nothing to measure and `Dropdown` could not return focus. Console warning only. - The light theme's warning badge sat at 2.66:1. - `Dropdown`'s Escape closed the dialog behind it as well. Vite's build input is `index.html` alone, so this never enters the production bundle. |
||
|
|
59b93f9bca |
test(desktop): guard the mock paths and update suites for the new markup
`mockPaths.test.ts` asserts every `vi.mock` specifier resolves. A mock pointing at a moved module is not an error — Vitest registers the factory against a specifier nothing imports, the real module loads, and the test keeps passing with its isolation quietly gone. That is what happened to `RepositoryLaunchControls.test.tsx`, which kept mocking `./DirectoryPicker` after the component moved to `composite/`, so the real picker and its API calls rendered in a test that believed they were stubbed. Suite updates for markup that legitimately changed shape: - Dropdown entries are `role="option"` now, not buttons. `<button>` is invalid inside a `role="listbox"` and left the dropdown without arrow keys; the assertions follow the corrected semantics. - `PetSettings`' toggles report `role="switch"`, the ARIA role for a toggle. They were bare checkboxes while `McpSettings`' equivalent was already a switch — the two are now consistent. - `pages.test.tsx` drops its references to the two deleted mock pages. |
||
|
|
f64745e569 |
refactor(desktop): adopt the component library in pages
Native buttons in `pages/`: 88 -> 33. An earlier pass over this directory converted only 15 sites and reported the blocker precisely: `Button` offered h-5/h-6/h-9/h-10 while nearly every page button is h-8. Adding `base` unblocked 17 conversions on its own. `IconButton`'s `bordered` and `hoverTone` unblocked the trace row actions, whose treatment is transparent-with-a-border and whose delete must not sit red at rest. - `ComputerUseSettings` moves 21 stock palette classes onto token pairs, retiring 11 alpha modifiers with them. Its allowlist entry is gone rather than reduced. - `ActivitySettings`' hand-rolled `createPortal` dialog becomes `Modal`. - `TraceList`'s `RowAction` and `TraceSession`'s `IconAction` helpers are deleted in favor of `IconButton`. Three dead files removed: - `pages/NewTaskModal.tsx` — zero importers; the live one is `components/tasks/NewTaskModal.tsx`. It was also the largest remaining holder of the deprecated `--color-primary`. - `pages/ToolInspection.tsx` and `pages/AgentTeams.tsx` — neither appears among the nine pages `ContentRouter` and `AppShell` import. Only `pages.test.tsx` referenced them. `Settings.tsx` keeps two stock palette classes. They sit inside a hardcoded white QR box that scanners need for contrast; theme tokens go light-on-white under the dark theme. Converted, reverted, and commented. Left native (33): settings nav rows, the dnd drag handle, tree nodes, `role="tab"` and `role="gridcell"` elements, and the theme/language pickers — `SegmentedControl` emits radio semantics while two tests pin `role="button"` with `aria-pressed`, which is an a11y decision rather than a styling one. |
||
|
|
fecc437be7 |
refactor(desktop): adopt the component library in settings, trace, browser
Native buttons across these directories: 42 -> ~30. - `browser/` moves off hardcoded Chinese onto `useTranslation()`. - `AgentManager`'s `MetaPill` becomes `Badge bordered` across 11 render sites; its tool search becomes `SearchField`. - `trace/` filter chips become `SegmentedControl`, which adds arrow-key navigation and a `radiogroup` they never had. - `PermissionModeSelector` had a hardcoded DOM id rendered in two places. `useId()` alone was not enough — a nested duplicate `role="menu"` wrapper still emitted it twice, so that came out too. - `ReasoningEffortPopover` defaulted its `ariaLabel` to a Chinese literal, announcing Chinese in every locale to any caller that omitted it. - `DoctorPanel`'s status label was bare `--color-warning` text, an AA failure; `Badge tone="warning"` uses the on-container pair. - `TeamStatusBar`'s progress bar becomes `Progress`, retiring the twin of the one in `SessionTaskBar` — they matched down to the comment. - `PetApp`'s error banner leaves the stock rose palette for error tokens. `Progress` here uses an explicit tone rather than `tone="auto"`: this bar turns green when nothing is *running*, which is not the same as 100% (one done plus one errored is green at 50%). `ModelSelector` and `PermissionModeSelector` get only the safe changes — outside-click and icon buttons. Their selection logic is untouched; both couple five to seven stores and sit on the highest-frequency path in the app. Left native: tree nodes, `role="menuitem"` rows, model option rows, heatmap cells, and the pet sprite controls. |
||
|
|
6e6829ac79 |
refactor(desktop): adopt the component library in market, plugins, tasks
Native buttons across the five directories: 37 -> 14. This is where the badges and empty states were densest. `ConfirmPopover` moves in from `shared/` to `tasks/` — one caller. - `InstallStateBadge` and `SecurityBadge` become their status maps and nothing else, which is what `Badge` exists for. Both also had `success` text on a `success-container` fill; the tone pair fixes a contrast failure neither had noticed. - Long-content badges (hook matchers, package specifiers, file paths) needed `wrap` — badges are single-line by default and these overflowed. - `TaskRow`'s hand-rolled `mousedown` becomes two `useDismissable` calls, one per overlay. Escape now closes them, which it did not before. - `MarketHome`'s error banner keeps its layout but drops `border-[…]/35` + `bg-[…]/25` for solid tokens and gains `role="alert"`. Those alpha modifiers meant the banner had no panel at all on Safari 15. One real regression fixed: `FilterBar`'s filter chips were dead. When `Dropdown` started cloning its trigger to attach ref, aria state and its own `onClick`, `FilterTrigger` — a plain function component forwarding none of it — silently swallowed them, and clicking a chip did nothing. It now forwards. `FilterBar.test.tsx` is the regression anchor; verified it fails with the spread removed. Left native (14): `role="tab"` and `role="option"` elements, whole-card and whole-row click targets, and `DayOfWeekPicker`'s circular day toggles. |
||
|
|
3eb05bf33e |
refactor(desktop): adopt the component library in layout and workspace
Native buttons across the three directories: 81 -> 49. `Toast` and `UpdateChecker` move in from `shared/` — one caller each, both application singletons that belong with the shell. `Sidebar` was the hard case. Its 21 icon buttons hover to `--color-sidebar-item-hover`, which differs from `--color-surface-hover` in all three themes (it is a translucent white tuned for the sidebar's gradient). Overriding via className means two arbitrary `hover:bg-[…]` values competing, with the winner decided by stylesheet order — so `IconButton` grew a `surface="sidebar"` instead. Both local `ToolbarIconButton` adapters are deleted; `pressed` was the only thing they still added, and it brings `aria-pressed` they never had. `ProjectHeaderMenu` gains `forwardRef` so its four menus can share one `useDismissable`. Their triggers are click toggles, so `triggerRef` is what keeps a second click from reopening what it just closed. Three retry buttons drop five `/N` alpha modifiers on the way. Left native (49): menu items, tab items, tree nodes, file rows, the OS titlebar controls, `WorkspaceDiffSurface`'s cells, and the two sidebar collapse toggles — their `sidebar-toggle-button` class carries an active transform and drives the chevron animation, not just size and radius. `TitleBar.tsx` is deleted: zero importers. The audit listed its settings button among the unnamed controls, but it never rendered. |
||
|
|
359b375a79 |
refactor(desktop): adopt the component library in chat
Native buttons in `chat/`: 79 -> 47. `ProjectContextChip` and `RepositoryLaunchControls` move in from `shared/` — one caller each and two, so they belong beside the composer rather than in a directory named for common ground. Notable conversions: - `ChatInput` and `EmptySession` carried 59 byte-identical lines of outside-click handling, four effects each, `diff` output empty. Both now call `useDismissable`. - `StreamingIndicator`'s retry banner moves off the stock amber palette onto the warning token pair. Its `dark:` variants disappear with it — they only ever covered one of the three themes. - The composer's edit/delete pair: delete rests muted and reddens only on hover, since a delete icon that is red at rest reads as an error state. - Two badge maps lose their `/N` alpha fills, which Safari 15 WebViews drop outright. The run button's arrow now trails its label, matching the same control on `EmptySession`. The two were mirror images of each other — caught by opening the app, not by any test. `git show HEAD~5` confirms this predates the refactor. Left native (47): `role="option"` rows, tab strips, full-bleed disclosure headers with no radius, the session inspector's private `--color-inspector-*` palette, and the composer's plus/submit buttons — `ChatInput.test.tsx` pins those to 44px, and submit carries a dual fill (error container while stopping, brand gradient otherwise) that no variant expresses. `MessageList` keeps its structure untouched; its virtualization and `content-visibility` paths are not worth risking for a button. |
||
|
|
fbf407897c |
i18n(desktop): give every user-visible string a key
48 new keys across all five locales (en / zh / zh-TW / jp / kr), and the call sites switched to `useTranslation()`. Two classes of problem: - Buttons with no accessible name at all: the tab-strip scroll arrows and the terminal folder-browse control. They needed keys before they could be named, which is why earlier passes left them unnamed. - Hardcoded literals reaching users: English in `WindowControls`, `MermaidRenderer`, `ImageGalleryModal`, `CodeViewer`, `FindInPageModal` (which had no `useTranslation` at all), and Chinese in all of `browser/`. A hardcoded Chinese `aria-label` is the same defect as a hardcoded English one — it just fails for a different audience. Also repoints two keys borrowed from the wrong namespace, and drops the `scheduledPage.col*` family (5 keys x 5 locales) left orphaned when the table layout became cards. `TranslationKey` derives from `en.ts`, so a missing key in any locale is a compile error rather than a runtime blank — the key-set parity test covers the rest. |
||
|
|
4194f5d23b |
refactor(desktop): add components/composite for shared feature widgets
Components that legitimately reach into stores or the API — the thing `ui/` forbids — but that several features share. `DirectoryPicker` (5 callers) and `OpenWithMenu` (3) move here from `shared/` and `common/`. The entry price is one rule, and `contract.test.ts` enforces it: two or more callers. `shared/` had no such rule and ended up 35% single-caller components, each really a private part of one feature sitting in a directory that advertised itself as common ground. `OpenWithMenu` also switches to `useDismissable`, which was ported out of it in the first place — it held the most complete overlay behavior in the codebase. It keeps `mousedown` explicitly rather than taking the hook's `pointerdown` default, because two call sites document that contract and three tests assert `fireEvent.mouseDown`; flipping it belongs with those files. |
||
|
|
c0c6108e84 |
refactor(desktop): add components/ui as the primitive layer
24 primitives, of which 7 move in from `shared/` unchanged in behavior
and 17 are new. What they replace, by count from the audit:
- `Button` — 362 native buttons vs 102 uses of the old one, a 22% reuse
rate. It was bypassed for a reason: no focus ring, no icon-only mode,
and none of its three sizes pinned a height, which is the direct cause
of 18 distinct button heights. Sizes now pin `h-*` reproducing what the
old padding rendered, so adopting it moves nothing.
- `IconButton` — the largest single category: 76 icon-only buttons across
40 files, in 15 radii and 12 sizes, with a focus ring on 41% and ten
with no accessible name at all. `label` is required rather than
optional-with-a-fallback, because a fallback lets unnamed buttons keep
shipping.
- `Spinner` — resurrects the dead `shared/Spinner.tsx` (zero importers)
whose SVG path was character-identical to a private copy inside
`shared/Button.tsx`, two files apart.
- `Badge` / `StatusDot` — 45 pills and 41 dots. `market/InstallStateBadge`
and `market/SecurityBadge` had character-identical shells differing
only in their status map.
- `EmptyState` / `ErrorState` / `LoadingState` — 22 + 12 + 19 sites, plus
five local components sharing three names with incompatible props
(`{icon, text}` vs `{title, body}`).
- Input family — of 76 native inputs, `focus-visible:` appeared zero
times, `disabled:` styling zero times, `aria-invalid` once, and three
had no visible focus at all. Ids now come from `useId()`; deriving them
from label text collides for any two Chinese labels, since the
space-stripping is a no-op there.
- `SegmentedControl` — 10 implementations with 6 active-state treatments,
five of them inside `pages/Settings.tsx` alone. None of the four
`role="tab"` sites had a `tablist` parent or arrow keys.
- `Dropdown` — keeps its API but gains listbox semantics, arrow keys and
focus return. Its trigger is now cloned to carry the ref and aria
state, so it must reach a real DOM element; a dev-only assertion warns
when a custom trigger swallows the props, which is otherwise silent.
- `Skeleton` / `Card` / `Progress` / `Tooltip` / `Switch` / `Checkbox`.
`contract.test.ts` encodes what makes this directory different from
`shared/`, which failed precisely because its name carried no rule: no
store or api imports, no imports from sibling feature directories, no
hardcoded hex, no Tailwind radii (`rounded-lg` is 8px while
`--radius-lg` is 12px — same name, different value).
|
||
|
|
57cac856f1 |
refactor(desktop): establish the theme and behavior foundation
First of a series that extracts a component library out of the desktop UI. Intermediate commits in this series do not build on their own — the directory move and the call-site updates cannot be separated per file. Theme layer fixes, all previously invisible to any test: - `dark:` compiled to `prefers-color-scheme` and so followed the OS, not `<html data-theme>`. With the OS in light mode the variant never fired, which is why the streaming retry banner rendered near-white under the dark theme. Bound it with `@custom-variant`. - Six tokens were referenced but never defined, leaving borders falling back to `currentColor` and panels transparent. Five were renamed to the tokens they meant; `--color-on-primary-container` was genuinely missing and is now defined per theme. - Status colors now come in pairs: `--color-<tone>-container` with a matching `--color-on-<tone>-container`. Using the accent as its own foreground measured 2.66:1 in the light theme, well under AA. - One ordered z-index scale replaces 17 ad-hoc values. Two orderings are load-bearing: dropdowns sit above dialogs (a modal blocks the page, so an open dropdown belongs to the topmost dialog — this is why DirectoryPicker had to hardcode `zIndex: 9999`), and toasts sit above sheets (otherwise feedback raised from a sheet is invisible). - `animate-in slide-in-from-*` came from `tailwindcss-animate`, removed in the shadcn rollback. Toast and Dropdown kept the classes and lost their entrance animation entirely; real keyframes now back them. - The embedded terminal hardcoded 20 hex values, bypassing the per-theme `--color-terminal-*` tokens that were already defined. `terminalTheme.ts` resolves them for xterm, which cannot read CSS variables itself. Behavior: - `useDismissable` replaces 21 hand-rolled outside-click effects, split across `mousedown` (14), `click` (3) and `pointerdown` (3). It defaults to `pointerdown`: `mousedown` is unreliable for touch, which is the shape of the "tapping outside doesn't close the menu" reports on H5. - `useAnchoredPosition` merges four viewport calculations whose margins had drifted to 8, 8, 12 and 0, only one of which flipped. - `chat/clipboard.ts` moves to `lib/` — it had a base primitive (`CopyButton`) depending on a feature directory. Guards, because conventions in prose do not hold: - `tokenUsage.test.ts` — every `var(--token)` resolves, and no raw z-index escapes the scale. A typo'd token throws no error; it just renders transparent. - `contrast.test.ts` — computes real ratios for 3 themes x 6 tones and requires AA. Verified by reverting the fix and watching it reproduce 2.66:1 exactly. - `paletteEscapes.test.ts` — a ratchet on stock Tailwind palette classes, which do not follow `data-theme` and which `tokenUsage` cannot see because they are not `var()` references. |
||
|
|
8f3a2f092c |
fix(desktop): let the dragged pet reach the macOS menu bar
The mascot sits at the bottom of a mostly transparent 384x400 window, so clamping a drag against the mascot rather than against the whole window deliberately asks for a negative y that pushes the padding above it off-screen. macOS runs a visible window's frame through -[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the work area back down to its top edge -- silently. The request was refused, the controller never noticed, and the mascot stranded a padding-height below the menu bar: 208px of dead band on this display, with ~/.claude/cc-haha/pet-window.json recording y=-175 for a window that never left y=33. Measured on Electron 42.7.0 / Darwin 25.4: only the top edge is constrained. Off-screen x and off-screen bottom y are kept verbatim, which is why the other three edges always worked. Nothing else escapes it -- not window level (all eight, up to screen-saver), movable, frame, transparent, panel type, and not positioning the window while hidden, since showInactive re-runs the constraint. enableLargerThanScreen is the one switch that skips the method, and it leaves size, getContentBounds and ordinary moves untouched. clampPetWindowPosition is already a complete four-edge bound, so opting out makes it the only clamp rather than removing one. Restoring then exposed a second problem the constraint had been hiding. A saved position deliberately leaves the padding off-screen, but the window is created before the renderer reports any region, so getPetWindowBounds clamped it against the whole window and opened a padding-height lower -- a jump of zero while the window was pinned, 208px once it is not. Persist the mascot box next to the position so the first frame lands where the drag left it. State written without one still restores the way it always did, and an empty box is dropped rather than trusted. The fake window accepted every y, so no top-edge assertion could fail and the suite stayed green through all of this -- the same blind spot that let the Windows DIP bugs hide. It now applies the constraint the way AppKit does, only while visible and only when the constructor options did not opt out, so the assertions prove the fix rather than the platform. Reverting either half turns the new cases red. |
||
|
|
db99af538c |
fix(desktop): render pasted images as previews, not file chips
c2774fc1 routed every pasted file through its native path so non-image files could be attached, and `pathToComposerAttachment` labels all of them `type: 'file'` with no preview source. AttachmentGallery only draws an <img> when `previewUrl` or `data` is set, so a pasted image rendered as the generic file card -- until the tab was reopened, when the same attachment came back from the transcript's inlined image block with base64 `data` and displayed correctly. Live and restored display had drifted apart. Re-inlining the bytes is not the fix: ba28e444 made desktop attachments path-only after a 12-file send inflated the payload to 50MB, and that still holds. attachmentImages resolves an image attachment's source instead -- previewUrl, then data, then an absolute path streamed back through /api/filesystem/file, the endpoint InlineImageGallery already uses for model-produced images. The websocket payload stays path-only. The extension set deliberately matches ConversationService's shouldInlineImageAttachment rather than everything that endpoint can serve: promoting .svg or .avif to `type: 'image'` would make the server inline a media type the API rejects. Those keep the file card, as does an image whose preview cannot load -- a moved file, or a path outside the roots the local server may read. What reaches the model is unchanged; the server already inlined pasted images by extension, so this `type` never decided that. |
||
|
|
f0d740cef2 |
fix(desktop): render skill frontmatter as metadata, not markdown
SKILL.md opens with a YAML frontmatter block. Passing it straight to the markdown renderer made the closing `---` a setext heading marker, so the whole block rendered as one giant bold <h2> above the document. Split the block out before rendering and lay it out as structured rows. Where it goes differs by context: - The detail sidebar, next to the market metadata. `slug`, `license` and `allowed-tools` are the same kind of data as `author` and `category`, just from a different source, and it is reference material -- it should never precede the document that explains what the skill does. Short scalars mirror the meta rows above them; arrays and long text stack, because 300px cannot do both on one line. - The file preview, as the file's header block. There the frontmatter genuinely is the top of the file being read, so removing it would misrepresent the file; it is styled as a header rather than a card so it does not compete with the body. This also covers SkillHub, whose `description` is the raw SKILL.md -- so the overview tab hit the same wall of bold text -- and puts ClawHub's already-parsed `descriptionFrontmatter` to use for the first time. Local installed skills previously flattened frontmatter into sidebar rows as comma-joined strings; they now share the same panel. The parser is a deliberately small YAML subset (scalars, inline and block sequences, block scalars) rather than a new dependency, since skill frontmatter is flat by convention. It never throws: a malformed or unterminated block returns the document untouched, and nested mappings are kept as raw YAML so nothing is silently dropped. Separately, the files tab was capped at a hard-coded 520px, leaving the bottom of the page empty. `min-h-full` on the flex column was not enough: a container sized by min-height keeps `height: auto`, so `flex-1`'s `flex-basis: 0%` cannot resolve and the panel falls back to content height -- measured 1215px of content inside a 720px viewport. A definite `lg:h-full` makes the panel claim the leftover space. Below lg the page scrolls as before. Walked through in a browser at three widths in both themes. check:desktop is green: 227 files, 2562 passing. |
||
|
|
7d2a8a3cdb |
fix(server): keep loopback trusted without the desktop process token
4c3b08c0 made `isLocalTrustedRequest` return early on
`localAccessTokenConfigured`, so once the desktop shell injects
CC_HAHA_LOCAL_ACCESS_TOKEN the loopback address stops being evidence of
anything -- only the token grants trust. The system browser never has it.
`shell.open`ing the Grok OAuth success page landed on
{"error":"Unauthorized","message":"Missing H5 access token"} instead of
the success HTML, and /preview-fs links, /local-file links and plain curl
went the same way. With H5 access off it was worse: 403 "H5 access is
disabled", which no H5 token can clear.
The classifier had no way to tell the two apart. A scenario matrix over
the policy functions returned the identical verdict for "system browser
opens the OAuth page" and "malicious site fetches 127.0.0.1" -- the rule
bought its safety by refusing every request that is not the app itself.
What that early return actually added over the checks below it was one
case: a reverse proxy on this machine perfectly mimicking loopback. A LAN
client is already rejected on clientAddress, a cross-site fetch on Origin,
and a tunnel forwarding its original Host on isLoopbackHost(url.hostname).
Reaching the remaining case means running a process on the user's box,
where ~/.claude is readable anyway. Loopback goes back to being trusted on
its own.
The boundary worth keeping is narrower than the whole surface, so
requiresLocalAccessCredential pins it to /api/h5-access: another program
on this machine must not be able to publish the user's sessions to the
network. /api/h5-access/verify stays open -- checking a token the phone
already holds is not a control-plane change -- and a server with no token
configured does not lock itself out.
Restoring loopback trust re-exposed an older hole that the blanket rule
had been covering: `if (!origin) return true` also accepts cross-site
subresource loads, since <img src="http://127.0.0.1:3456/api/...">
carries no Origin. Fetch Metadata separates them -- a real navigation
sends Sec-Fetch-Mode: navigate, a subresource does not -- and clients
that send no Fetch Metadata at all (curl, adapters, the CLI subprocess)
are not a browser CSRF vector, so they stay trusted. This is net new
protection; it existed neither before 4c3b08c0 nor after.
desktopRuntime resolved the token inside a Promise.all with getServerUrl,
so an IPC failure took the whole startup down with it. It degrades to
null now, which is the same principle: the token raises what the shell
may do, it is not a precondition for running.
Verified end to end against a server started with the token injected: the
OAuth success page returns 200 and its HTML, /api/status, /api/sessions
and /api/adapters return 200 tokenless, while the control plane, an <img>
subresource, a cross-site fetch and a proxy hop all return 403.
check:server is 224 files / 2379 passing, desktop 225 files / 2522.
|
||
|
|
6900f7f9f4 |
fix(desktop): surface a plugin reload failure instead of reporting success
pluginStore applies a plugin change, reloads the runtime, and resolves either way -- a failed reload is recorded in `refreshWarning` and the action still returns its success message. Nothing reads that field, so "disabled the plugin but the runtime never reloaded" reached the user as a plain success toast, indistinguishable from a clean run. The mutation failing still throws and still surfaces, so this was not fully silent; the half where the change lands but the reload does not was. PluginDetail now reads `refreshWarning` after the call -- the store clears it when a mutation starts, so anything present belongs to that action -- and reports a warning naming the reload failure. Kept in the component rather than the store: no store in this codebase reaches into useUIStore, and adding the first such call to fix a toast is not the place to introduce that. |
||
|
|
4404adf804 |
chore(desktop): re-drop the dead files the rollback resurrected
5b4e224f deleted these four as part of the migration cleanup; e9b53f68 rolled back its UI half and brought them along. They were already dead at c2774fc1 -- the pre-shadcn baseline -- so this restores a cleanup rather than removing anything the rollback needed. Nothing imports them: neither a relative nor an @/ import of any of the four appears anywhere under desktop/src, tsc is clean, and the suite still reports 2520 passing. The other files the same revert restored stay: shared/Button (23 importers), shared/Input (7), shared/Dropdown (4) and pages/NewTaskModal (3) are all live under the reverted UI. |
||
|
|
e04337342f |
fix(desktop): drop the installed-skills locale keys the rollback restored
b64069a3 removed the installed-skills overview and its nine market.installedSkills.* / market.section.installed keys from all five locales. e9b53f68, its immediate child, reverted the locale files wholesale -- and because the overview (8061b9b2) predates the shadcn migration those keys were inside the range being restored, so they came straight back. The component that read them is gone, and nothing else references them: `git grep -E "market\.installedSkills|market\.section\.installed"` outside the locale files returns nothing. Every locale kept the same key count, so a parity check has no way to notice. Re-applies b64069a3's deletion, and adds a test that fails if the keys reappear -- the previous deletion was undone by a mechanical revert, so a guard is worth more here than the removal itself. |
||
|
|
8678e49a4d |
fix(desktop): rebuild the preview-agent bundle after the UI rollback
`preview-agent.js` is a tracked build artifact, bundled from desktop/src/preview-agent/ by build:preview-agent and shipped as a packaged resource. The rollback (e9b53f68) deleted that source's theme.ts and trimmed editBubble.ts, but never rebuilt the artifact -- it appears in neither revert's file list, so the committed blob was last produced at ad597ffe. The result is a bundle built from source that no longer exists: it still carries all 12 --cc-haha-* custom properties the deleted theme.ts defined, plus the shadcn-era brand hex, and lacks the literal styling the current source emits. It is internally consistent so nothing crashes -- the packaged app just renders styling that diverges from what the repository says it should. Rebuilt from the current source. Verified byte-stable across repeated builds, so this does not reintroduce artifact drift. |
||
|
|
e9b53f6848 |
revert(desktop): roll back the shadcn UI migration, keep backend hardening
The shadcn migration (ad597ffe) and its follow-up (5b4e224f) were mega-commits: only ~16% of the insertions were UI work. Rolling either one back wholesale also deletes ~3400 lines of unrelated server, Electron, and store hardening -- and because each of those changes shipped with its own tests, the suite stays green while the hardening silently disappears. This rolls back the UI layer only. Reverted (back to c2774fc1): - desktop/src/components, pages, features, theme, i18n - components/ui/**, components.json, src/lib/utils.ts (cn helper) - radix-ui, class-variance-authority, tailwind-merge deps - src-tauri/src/lib.rs hardening: dead code, nothing compiles it (no @tauri-apps dep, no cargo invocation anywhere, and scripts/pr/release-workflow.test.ts asserts Tauri's absence) Kept (identical to main): - src/** -- WhatsApp authDir escape allowing arbitrary recursive directory removal, computer-use tier bypass granting every pre-authorized app full tier, plugin uninstall keepData inversion that deleted data when asked to keep it, plugin project-scope writing to the sidecar cwd, diagnostics share leaking provider config, memory API TOCTOU plus atomic writes - desktop/electron/** -- PTY leak on renderer destruction, IPC boundary validation rejecting NaN and unbounded input - desktop/src/stores/** -- request-id race guards - desktop/src/api, lib/desktopHost -- kept in step with the stores Follows main's b64069a3 in dropping the installed-skills overview. Updated two memorySettings assertions to expect the optimistic-lock fields the retained memory store sends. check:desktop pass (lint clean, 225 files / 2519 passed / 1 skipped, build ok). check:server pass (224 files / 2375 passed / 0 failed). |
||
|
|
b64069a301 |
revert(desktop): drop the installed-skills overview from the market
The overview added in #1098 duplicated Settings -> Skills: same useSkillStore, same fetchSkills/fetchSkillDetail, and it even reused the settings.skills.* translation keys. Its only new logic collapsed the five skill sources into a personal/system pair, while "is it installed?" was already answered by InstallStateBadge on each market card. In exchange it pushed the actual catalog below the fold behind a 93-row list. Also reverts f82b596f, which existed solely to give that overview a working detail view; with the entry point gone it was dead code. Restores desktop/src/pages/Market.tsx and MarketHome to their pre-#1098 shape (verified: empty diff against f82b596f^ and 6b7e90ae^1), and drops the nine market.installedSkills.* keys from all five locales. |