* feat(app): separate content typography from interface size
Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.
* test(app): cover independent content typography
The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.
* test(desktop): cover independent content typography
The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
* feat(app): workspace labels baseline
Existing labels implementation, committed as-is as the baseline for the
UI redesign. Server, protocol, catalog store and sync are sound; the UI
layer is reworked in the commits that follow.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): show workspace labels on the sidebar meta line
Labels could be assigned and then appeared nowhere. They now draw as a
chip on the workspace meta row, after the host, change request, checks
and service, and they can be switched off from the Show submenu like any
other item on that line.
One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind
500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set
identity-colors.ts was written to replace. Label color is now the
identity color of the same name, so a chip, a host badge and a CI check
on one row sit in one contrast band. `workspace-labels/colors.ts` and its
per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx`
are the only places a label color becomes ink.
A row resolves its labels against its own host's catalog and nothing
else. The cross-host projection answers too, but two hosts may give one
name two colors and the merge settles that by target precedence and then
alphabetical serverId — a row painted in a color its host never assigned
is lying about the one thing the color is for.
The meta row's doc comment argued against tinted chrome on that line. The
chip is a deliberate exception — everything else there reports state and
shares the state colors, a label is a name a person chose — so the
comment now says that instead of contradicting the code.
Alignment:
- The chip is exactly as tall as the line: text at the row's 16pt
lineHeight, no vertical padding, so a labelled row is the same height
as an unlabelled one.
- A label run that opens the line hangs left by the chip's own padding,
landing the name on the rail the title above it uses. Mid-line the
ground stays in the flow.
- The swatch row reaches 44pt through hitSlop growing outward from a 20pt
swatch, replacing ten 44pt colored blobs in the manager modal.
- The picker's raw `<Plus>`, `<CheckSquare>` and `<Square>` go through
withUnistyles with module-level mappings, at the menu's 14pt.
Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict
object, so adding an item without a key there fails the whole settings
write and silently loses every other Show toggle with it. A test now
covers each item in SIDEBAR_ROW_ITEMS.
Phase 1 of the workspace labels redesign. The filter menu, the picker's
interaction model and the manager modal's layout are later phases.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): one Labels filter page with tri-state rows
Filtering by label cost three levels: Display -> Labels -> Include -> a page
of labels, or -> Exclude -> the same page again. It is now one page: every
label across every host, one row each, leading with its colour dot.
A row cycles off -> include -> exclude -> off. Include is MenuItem's own
check; exclude is lucide's X, the check's own pair, at the same 16 in the
same trailing slot, so the column does not shift as a row cycles. Exclude
also dims the row, because a state you have to identify by its glyph is not
one you read at a glance. Unlabelled is a row in the same list, with a hollow
ring where the others carry their colour.
Match any / all still appears only once two labels are included. Clear is
absent, not disabled, when nothing is filtered. Manage labels sits at the
bottom below a separator. The whole root row is gone when no host has a
label — unless a filter is still active, since the merged catalog only counts
online hosts and losing one would otherwise strand a filter that is still
hiding workspaces.
The page is titled Labels, so its rows stop repeating the word: "Match any",
"Match all", "Clear filter". The two Match rows sit directly under the list
they qualify and only appear once two labels are included, so the subject is
already on screen. All nine locales move together, translated rather than
transliterated — Arabic keeps its noun, where "أي" is a determiner that needs
one to stay grammatical.
SidebarLabelFilter collapses from four collections plus match to one
Record<labelKey, "include" | "exclude"> plus match: a tri-state row is one
value per label, so include and exclude can no longer disagree about the same
name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName
makes unreachable for a real label. toggleSidebarLabelSelection,
countSidebarLabelSelections, sidebarLabelSelectionIncludes and
deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes
cycleLabelFilter and setLabelMatch, so the store owns its own transitions.
No migration: labels are unreleased, so the old persisted shape never
shipped, and migrateSidebarViewState already resets on a safeParse failure.
The e2e manager step asserted "Red label color" on a swatch that Phase 1
renamed to "Red"; the selector follows the code, and the accessibility.color
key behind the old string is deleted from all nine locales as unreferenced.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): assign labels on the menu's own rails
The assign picker was a search box, a create row that unfolded an inline form
under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and
name off the rail the rows above them sat on. Nothing lined up with anything.
Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as
the trailing check the engine already draws for a chosen row. Using the
engine's row is the alignment fix; there is no second row implementation to
keep in step.
Every press toggles and the menu stays open — assigning labels is multi-select,
so the dual-target model (`source: "row" | "checkbox"`,
`shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close`
dependency. You leave by dismissing the menu.
Search is deleted. Nobody has enough labels to search, and the field was the
thing pushing the list down the surface. `workspaceLabels.search` and
`createNamed` go with it across all nine locales; `clearSearch` stays because
the manager modal still owns a search field (Phase 4's copy, untouched here).
`Create label` moves below a separator at the bottom and opens a page that
replaces the list: a name field, the swatch row, one commit row. Picking a
colour picks a colour — it no longer creates the label. A failed create keeps
the draft and shows the error on the page; a successful one creates, assigns,
and returns to the list with the row ticked.
Both menus share one page definition. `SidebarWorkspaceMenu` and
`SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call
`useWorkspaceLabelMenuPages`.
Menu engine, all of it forced by putting a text input on a menu page:
- `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry
and lands its text 4pt inside the labels above it. This one is drawn as a
row's own fill, so the box and the text sit on the row's two rails at once.
- `MenuPageDefinition.hoverIntent` — a page you type into is not one the
pointer opens or dismisses. Without it the create flyout opened on a pointer
passing over the row, and threw the draft away when the mouse drifted off it
while the hands were on the keyboard.
- The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With
`enableDynamicSizing` the largest snap point is the content's own height, so
a short page did not grow and the keyboard came up over the field. Caught on
an iOS simulator, fixed, and re-shot.
- `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said
so; a check drawn and never announced would have been an accessibility
regression. Tri-state rows (the filter page's exclude) still cannot announce
themselves — menu-wide, still Phase 5's.
`sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact`
is kept deliberately. On a compact web viewport there is no hover, so the row's
trailing actions and kebab were unreachable and the labels menu with them; this
is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md.
Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the
create page on iOS with the keyboard up
(/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced
(-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten
for the new model, daemon-backed failure coverage kept for both the toggle and
the create path, green with the menu unit suites.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): edit a label in one operation
`workspace.label.rename.request` and `workspace.label.recolor.request` become
one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs
are deleted outright — no COMPAT shim, because the labels feature is unreleased
and neither name has ever been on a wire a user owns. The `workspaceLabels`
capability flag stays; that one is how a new app detects an old daemon.
Two calls could land half-applied, which left the catalog in a state nobody
asked for and the UI with nothing true to say. One catalog commit now applies
the name, the colour, or both. Renaming onto a name the host already has is
rejected rather than merged, and the check runs inside the planner so a refused
collision applies neither field. A label keeping its own key is not colliding
with itself, so case-only edits pass.
The manager modal follows:
- The host selector is `HostFilter`, the pill History and Schedules use.
`includeAllHost` is now a prop: a surface that edits one host's catalog
cannot carry out "all".
- Search moves to the sheet header, where it spans the header's own rails.
As a body control it capped at the component's 420pt and left a hole on the
right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch`
goes with the box that had a clear button.
- A row no longer opens an editor. It carries a pencil in its trailing slot,
hover-revealed on web and always visible on native and compact, on the
canonical plain-View hover pattern with the press on an inner Pressable.
- The edit view is a name field, the swatch row, and one Save that issues one
update. Delete keeps its confirm and its affected-workspace count, and moves
to `outline` — red belongs to the dialog, not to the surface that opens it.
- Copy says edit, never rename or recolor, in all nine locales.
`workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client
appends to every RPC failure. That is what a log wants, not what a user reads
under a name field.
`workspaceLabels.accessibility.color` stays deleted: the swatch's accessible
name is already the colour's name, so nothing needed it back.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* chore(app): converge the labels phases
The sweep at the end of the labels redesign: the three items earlier phases
found and kept out of their own commits, one cross-phase pass, and the docs.
`AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead
of `padding`, which had inset its glyph 8pt from the rail the title, the search
field and every trailing glyph below it sit on. This is shared chrome, so it
moves the close glyph in every sheet in the app; measured on four of them
across both paths — the labels manager desktop and compact, the shortcuts
dialog, the rename sheet — and each one's trailing rail now agrees with its
leading one.
`MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a
screen reader: the check announced itself after the last phase, but exclude had
no state at all, so an excluded label was indistinguishable from an untouched
one. `"mixed"` is ARIA's own third state, the engine still draws the check only
for `true`, and the row keeps supplying its own trailing mark — nothing in the
menu engine learns what a label is.
CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/
`onPointerLeave`", which is the opposite of what `docs/hover.md` and
`constants/platform.ts` both mandate. It now names the real rule — hover on a
plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/
`onHoverOut` on a `Pressable` that contains other pressables — and links the doc
that owns it rather than restating it.
The cross-phase pass turned up one real defect: the replica cache never learned
about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace
came back unlabelled, and since the directory cursor is current on reconnect the
daemon has nothing newer to send — the chips stayed gone for the whole session
and the filter matched nothing. An optional field, a serializer line, and an
assertion in the restore spec.
Everything else measured onto its rails already. Left alone and logged: the
per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing
when the sidebar swaps to its empty state, which is confined to that swap and
predates every phase here.
`docs/architecture.md` and `docs/data-model.md` described rename and recolor;
they now describe the one update commit, which fields it rewrites, and the
per-host resolution a row's chips use. `docs/menus.md` documents the third item
state where it already documents the other two.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): split the labels filter into include and exclude controls
The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare
operation to the common one: clearing an include cost two presses and routed through exclude, so
the sidebar visibly re-filtered into a state nobody asked for on the way out.
The row now owns include and a control on its trailing rail owns exclude. Each is one press each
way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and
`toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the
other for that label, so including an excluded row is still one press.
A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check,
a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason
Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The
control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it
nor changing state moves anything: measured 453-469 on every row, wide and compact.
The control carries a tooltip, gated to web where hover exists. It portals into the overlay root
above the flyout with no backdrop of its own, and resting on it does not read as the pointer
leaving the surface, so the menu stays up.
Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and
it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without
hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The
row keeps Phase 5's `selected="mixed"` for exclude.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): put include and exclude on their own rails on every label row
Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone
but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it
reads as delete rather than exclude, and one rail with a changing occupant still means the row's
trailing area keeps changing identity as you read down the column.
Both controls are now on every row in fixed positions, and state is which one is lit. Nothing
appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an
included row, an excluded row and an untouched one alike, wide and compact. Because the ban is
always there, include -> exclude is one press instead of two. The row still includes, and the check
is that same action named.
`Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does
not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left
is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical
correction available before it stops being a glyph. The box is what the rail is measured from and
it stays 16.
Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious
choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s
own check in that colour to mean *selected*, so an unlit check in the same ink would say the
opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row.
Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons
carry no checked state of their own, so a label's state is announced once and its two controls read
as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`,
because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop.
Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0`
mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control
reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged;
both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are.
The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by
a rail measurement taken in every state — relative to the row, since filtering moves the anchor the
menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is
not a rail.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): reveal the label filter's controls, and stop the empty state closing the menu
Phase 7 put the check and the ban on every label row at all times, which made a resting list of
labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own
state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row
brings both controls up, the mark you have lit and the other one unlit beside it. So the column
you read down is the filter, and the row you are pointing at is the one offering to change it.
Nothing about the rails changed. The two boxes are laid out in every state and only their ink
comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one,
hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row
out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the
`MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable`
(docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never
means pressing something invisible; and `pointerEvents` follows visibility so a hidden control
cannot be clicked. On native and compact there is no pointer, so both simply stand there.
Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit
against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with
`hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named
actions, and both tooltips gated `enabledOnDesktop={isWeb}`.
---
Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed
in Phase 5 to the empty-state swap, owned by nobody.
`SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own
container and scroll view, and both branches were handed the same header element. Different
subtrees, so React unmounted the header on the swap — and the header holds the display menu's
trigger, so the popover went with its anchor. The empty state is now a body: both list modes
render it where their groups would go, inside the scroll they already own and below the header
they already render, and the swap can no longer reach anything above it. Cleared from that same
still-open page, the way back out is the swap in reverse and it holds too.
`ProjectModeList` was already the right shape — its "no projects yet" body sits below the header —
but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is
the same bug written as a guard: keep the header when there is a filter to undo. The label filter
joins that condition rather than getting a second mechanism. Both empty states move into
`sidebar/empty-states.tsx` so the two list modes can render either without importing each other,
which also leaves the card's geometry defined once instead of once per body.
Still conditional, and left alone: a sidebar with no projects and no filter has no header at all,
so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the
onboarding empty state, where a section heading over "Add a project" would be a heading over
nothing, and no menu is reachable in the state it lands in.
The e2e gets the step that would have caught this: exclude into the empty state, assert the page
you pressed on is still up, clear from it, assert the same. Verified by reverting both list files
to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as
they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for
the four states of the reveal.
Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an
included row with the pointer away, and the empty state with the menu still open over it, at wide
and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(app): make the label filter include-only
Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row:
tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons.
Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for
a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever
complained about that one.
A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing.
Nothing else. Which makes the alignment law trivial to satisfy, and that is the point.
Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes
back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur
plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the
lit/unlit token pair, `Ban`, and the two rails they held.
`SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the
same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The
exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` /
`exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the
record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current
schema before `migrate` ever runs.
Two things this leaves unused, both deleted:
- `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce
itself. The filter row was the only caller, so the union collapses back to `boolean` and the two
`selected === true` guards that existed for `"mixed"` go back to plain truthiness.
- The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing
control inside a menu row. No caller does that any more, so the doc was describing a pattern the
codebase no longer has.
Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any
other; `Match any` / `Match all`, because multi-select include still means two different things.
Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the
header's survival condition reads, and it answers the same question against the new shape.
Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state
with the menu still open over it, at wide and compact in both schemes.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): give label-grouped rows their project icons, and label groups a tag
Grouping the sidebar by label rendered every workspace row with its placeholder
initial. The same workspace under status or project grouping showed its real
project icon.
The components were reused; the data feeding them was not. One component served
both grouping modes and threaded the icon map through correctly. Fourteen lines
above it, a second `useProjectIcons` call was gated on `groupMode === "status"`,
written when status was the only mode that put icons on rows. Phase 2 extended
the render branch to `status || label` and left the gate alone. Nothing forces a
render branch and the data it renders to agree, and these two were far enough
apart to drift in silence. Reuse buys you one implementation of the drawing; it
buys you nothing about who decides what to fetch.
So the fix is the shape, not the line. There is now one `useProjectIcons` call
for every mode, and its targets come from `buildSidebarProjection` — the same
function that produced the rows, from the same `projects` list. A mode can only
render a row for a project the projection already knows about, so the map cannot
disagree with the screen. The gate is gone rather than extended: extending it
would leave the identical trap for the next grouping mode. `useProjectIcons`
dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was
a premature optimization that bought a bug.
Pinned by `sidebar-projection.test.ts`: for each of the three modes, every
project view key the projection will render — pinned rows, grouped rows, project
headers and the rows under them — has an icon target. Red on the old gate:
restoring `groupMode === "status" ? … : []` in its new home fails project and
label grouping with `['project', 'other-project']` uncovered, and status passes,
which is exactly the bug's signature.
The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one
more of the same shape, correct today: the projection chose its shortcut sections
with `status || label`, so a fourth mode would have silently walked the project
rows. Both the groups and the sections now come from one exhaustive `switch` on
the mode, which makes the next mode a compile error rather than an empty list.
`workspaceEntriesByKey` is already written the safe way round (`!== "project"`),
and the rest — host badges, pinning support, the empty-filter state — never read
the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is
`SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new
mode lands in the grouped list instead of the project list's `else`.
Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the
label's colour, muted for `Unlabelled` — the glyph the Labels menu and the
workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14
with no optical nudge, and that is measured rather than assumed: in the header's
leading slot both ink a 14x14 box at the same offset, and the tag lays down 76
ink pixels to the circle's 84, so reaching the corners of its box costs it no
weight beside the status glyphs.
QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and
the same workspaces under status grouping, plus the hover chevron swap, the
glyph zooms, and a before/after pair taken with the fix reverted to the bug's
shape.
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): align label chip typography
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* docs: format architecture module table
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): narrow workspace labels workflow
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): mirror bottom sheet test exports
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* test(desktop): assert project browse by destination
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain empty projects without label filters
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): preserve status grouping test contract
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* fix(app): retain filters after label load failures
Co-authored-by: Christoph Leiter <christoph@leiter.io>
---------
Co-authored-by: Christoph Leiter <christoph@leiter.io>
* feat(skills): manage orchestration skills per host
Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once.
* fix(client): keep skill management internal
Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins.
* fix(e2e): declare skills fixture dependencies
Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite.
* test(skills): use native managed paths
Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
The editor was trapped below the resident webview's body-level paint plane in split layouts. Render it through the shared overlay root while tracking the owning pane's bounds.
* fix(desktop): keep element selectors available on loaded pages
Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.
Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.
* test(desktop): await browser selector injection
Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.
* Harden browser selector session lifecycle
* Keep selector E2E fixtures independent
* fix(desktop): harden selector and annotation lifecycle
* fix(desktop): keep selector PR focused
* refactor(desktop): own element selector lifecycle
---------
Co-authored-by: Daniel G. Kang <180234654+dgk-dev@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.
* fix(desktop): treat unpublished updates as unavailable
GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.
* refactor(desktop): inject updater runtime dependency
Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.
* fix(desktop): preserve lazy updater initialization
* revert(desktop): drop updater runtime refactor
* fix(desktop): restore direct LAN connections
Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression.
* fix(app): replace host connections by identity
Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
Render and search the shortcut chord that actually fires, including rebinding default-only display tokens such as question mark and index wildcards. Unassigned rows show no empty badge, and named-key overrides use the shared display vocabulary.
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
* feat(app): add AMOLED and all four Catppuccin themes
Add an AMOLED theme (pure black background) and the four official
Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the
appearance picker. Generalize the dark-theme builder to support a light
surface variant, register the new themes with Unistyles, and patch all
registered theme keys when applying font/syntax appearance settings.
* fix(app): localize Catppuccin + AMOLED theme labels across language packs
The initial theme commit pasted the English brand/flavor labels into every
locale. Localize them following each pack's convention: transliterate
Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use
katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in
fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where
the pack transliterates (Arabic).
* fix(app): make Latte terminal ANSI colors readable
* test(app): cover appearance theme picker options
* refactor(app): scope theme PR to AMOLED
* refactor(app): rename AMOLED theme to Pure Black
* fix(app): refine Pure black appearance
* test(app): wait for sidebar toggle icon
* test(app): remove hover timing race
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
* fix(desktop): preserve browser tabs across focus changes
Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.
* fix(desktop): recover browser surfaces after lifecycle changes
* fix(desktop): preserve resident browser lifecycle state
* fix(desktop): seed inactive browser viewport
* fix(app): keep an open workspace menu from unmounting its own trigger
The sidebar kebab is revealed by hover, and on a compact layout the menu it
opens is a sheet that slides up over the row it came from. The row un-hovers,
the trailing overlay unmounts the trigger, and the menu's open state goes with
it — nothing is left that can close the surface already presented in the
portal, so a full-screen backdrop swallows every click until reload.
Lift the menu's open state to the component that decides whether to render the
kebab, and keep the kebab rendered while the menu is up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): follow the sidebar and host badge redesigns in the e2e suite
Three specs were still driving surfaces that moved out from under them, so CI
has been red on main since the sidebar polish landed.
Display preferences became one row per decision with the options a page down,
so grouping and the host filter need that page walked first. Five specs had
their own copy of the two-line dance; it lives in the sidebar helper now, and
the host filter has its own opener next to the menu it belongs to.
The host badge is plain secondary text on the meta line and its identity colour
lives on the server icon alone, so the helpers stop asserting a tinted label
and a pill fill that the redesign removed.
Alignment rails are compared across canvas glyph metrics and SVG bounding
boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance
both landed on the wrong side of it in CI. Two pixels still catches the whole-
pixel offsets these rails encode. The compact sheet spec also read its rails
while the panel was still sliding in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(app): let a browser e2e run take a private Metro cache
Metro's on-disk cache is shared by every checkout on the machine. Editing one
app source file rebuilds that module against everyone else's cached output and
the Unistyles styles can come back desynced, which surfaces as alignment specs
failing with real-looking pixel numbers that go away when the edit is reverted.
E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone
else's entries, so the cache can be ruled out before a layout failure is
believed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): stop the chat scroll helper from hanging on a stalled sampler
`scrollChatAwayFromBottom` waited on a promise that could only ever resolve
from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does
not fire while the page is occluded, which is reachable on a CI runner, so the
sampler could stall on its first frame and the wait never returned — the test
died on its own 240s timeout with nothing to read. A missed wheel event did the
same.
Settling the scroll before measuring is an optimization; the assertion after it
is what decides the test. Sample on a timer with a deadline so a stall falls
through to that assertion instead of consuming the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(app): remove layout geometry e2e coverage
Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(nix): identify desktop app for icon matching
* fix(desktop): ignore wrapper class in CLI passthrough
* fix(nix): give the Electron launcher a real app root
The Wayland app_id — what a bar or taskbar uses to find the .desktop
entry, and therefore the icon — is fixed during Electron startup, so
neither `--class=` nor main.ts's `app.setName()` reaches it on current
Electron. Launching `electron path/to/main.js` gives Electron no
package.json to read a name from, so the window published the default
"electron" app_id and shells drew the generic Electron icon.
Launch a one-file app root named "paseo-desktop" instead, matching the
installed desktop entry and hicolor icon. Older Electron (38) derives
the app_id from the runtime app name "Paseo", so also ship a NoDisplay
alias entry under that name; the icon resolves either way and the
launcher list still shows a single Paseo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(ci): skip unrelated required checks cleanly
Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.
* fix(ci): gate changes to install patching script
* fix(ci): route cross-package test contracts narrowly
* fix(ci): keep routed contracts merge-blocking
* fix(server): exclude nested test utilities from builds
* fix(ci): cover app-owned native and browser domains
* refactor(ci): align tests with runtime domains
Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.
* fix(ci): keep routing and desktop suites isolated
The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.
* fix(ci): isolate desktop e2e runtime
* perf(test): isolate Playwright workers
* perf(test): parallelize Playwright within specs
* fix(test): keep Playwright file scheduling
* fix(test): share dynamic Metro fixture
* fix(test): preserve own-host restart fixtures
* fix(ci): preserve routed contracts after rebase
* Stabilize Playwright wheel-scroll assertions
* Reapply: Stop completed turns from appearing stuck (#2484)
* Reapply: Keep older chat history and image previews stable (#2490)
* Lock timeline layout stability with user-driven specs
Two reds reproduce the reported jank:
- new agent, first prompt: the row paints at top=100 then jumps to 144 through
authoritative hydration, because the draft tab and the agent panel each render
their own AgentStreamView for the same logical row.
- long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn,
because splitOrderedTail reclassifies measured rows into the virtualized segment
where the virtualizer falls back to estimateStreamItemHeight.
Also restores centered timeline rows: the pagination anchor wrapper introduced in
#2490 was a plain block div, which broke alignSelf: center.
* Measure the turn footer blink from when the footer appears
Bounding the window at the prompt row conflated two things: the footer arriving a
frame after the row, and the footer leaving the layout and coming back. Bounded from
the footer's first frame, the blink still reproduces, so the footer-ownership race is
real rather than a sampling artifact.
* Rename the shadowed prompt row binding in the frame recorder
* fix(desktop): allow concurrent worktree dev instances
Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state.
* fix(desktop): expose development providers in dev builds
Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production.
* fix(timeline): stabilize optimistic turn layout
Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions.
* fix(timeline): preserve optimistic prompts through hydration
Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions.
* fix(timeline): make submitted prompts canonical
Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge.
* fix(timeline): stop older history pages repeating entries
Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract.
* fix(timeline): resume agents from the latest history
Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination.
* fix(timeline): reconcile turn state after missed updates
Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence.
* test(timeline): cover running state across refresh
Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details.
* fix(timeline): keep history loading geometry stable
Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content.
* fix(timeline): keep turn activity coherent
Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority.
* fix(tests): align checks with runtime boundaries
* fix(desktop): stop dev app surviving its terminal
Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees.
* fix(timeline): preserve activity through turn handoff
Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state.
* refactor(timeline): remove submission timing state
* fix(timeline): restore prompt identity on resume
* fix(timeline): exclude attachments from canonical prompt text
Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged.
* refactor(agent): remove unused pending-run message identity
* fix(desktop): let Chromium allocate the debugging port
* refactor(agent): remove impossible finalized-turn tracking
* fix(timeline): load history after keyboard scrolling
Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination.
* test(server): preserve accepted turn stream order
The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract.
* test(app): scope sidebar fixtures by identity
Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec.
* fix(timeline): gate prompt revisions by client capability
* fix(timeline): finalize canonical prompt delivery
Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation.
* fix(server): preserve legacy prompt pagination
* fix(timeline): keep assistant block identity stable
Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows.
* fix(timeline): apply covered revisions during resume append
Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path.
* fix(server): preserve queued agent transitions
Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition.
* fix(server): snapshot queued agent updates
Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending.
* docs(app): date agent turn compatibility cleanup
* refactor(timeline): mint submitted prompt identity at acceptance
Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract.
* refactor(app): remove prompt revision reconciliation
Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates.
* test(app): cover submitted prompt rewind roundtrip
* docs(timeline): describe immutable submitted prompt identity
Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation.
* test(server): validate mock rewind targets
Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation.
* fix(server): validate rewind before interrupting
* fix(server): omit rewind identity for daemon prompts
* test(app): guard rewind eligibility and rejection
* fix(server): preserve prompt identity across hydration
* refactor(timeline): remove stale revision remnants
* Revert "fix(server): preserve prompt identity across hydration"
This reverts commit 6adcf7e5b0bc8601761af59b461a746cb5e395fa.
* fix(timeline): settle missed submission acknowledgements
* feat: make relay opt-in for device pairing
* fix(relay): keep pairing state authoritative
* fix(relay): refresh pairing state across transports
* fix(onboard): honor explicit relay opt-out
* fix(relay): surface transport startup failures
* fix(pairing): gate offers for older daemons
* test(cli): rely on real pairing transport coverage
* fix(cli): report live relay without a pid file
* fix(relay): synchronize config during daemon startup
* fix(relay): preserve mutability for ignored overrides
* fix(cli): use reachable daemon for pairing
* fix(app): redirect invalid pair device routes
* fix(pairing): support remote host device pairing
Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals.
* fix(pairing): validate pairing destinations
Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state.
* fix(pairing): render QR codes across platforms
Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack.
* fix(cli): pair from persisted relay state
Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
* feat(projects): add local icons and colors
* refactor(projects): simplify appearance handling
* fix(projects): serialize appearance saves
* test(app): update project appearance expectations
* test: fix project appearance CI expectations
* refactor(projects): organize appearance code
* fix(server): adopt main's reconcile model in project appearance updates
* fix(projects): preserve host-local appearance targets
* fix(desktop): restore Linux development startup
Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged.
* feat(projects): add custom project icons
Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet.
* refactor(settings): scope projects by host
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat(settings): choose which orchestration skills to install
The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.
"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.
The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.
* Restore skill selection after failed save
* fix(settings): don't save a skill selection that never reached disk
A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.
Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.
* fix(settings): make skills convergence single-writer and confirm removals
Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.
Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.
Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.
* fix(settings): confirm removal for skills installed in only some agent homes
The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.
The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.
A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.
* fix(settings): warn about every skill directory a save deletes
The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.
The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.
* fix(settings): a failed save keeps files the user added inside a skill
Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.
Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.
The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.
* fix(settings): make skill removal confirmation authoritative
* fix(settings): verify skill convergence before commit
* fix(settings): preserve concurrent skill edits on rollback
* fix(settings): preserve staged skill metadata
* fix(settings): stage skill deletions atomically
* fix(settings): stage removals per filesystem
* fix(settings): restore staged skill symlinks
* fix(settings): canonicalize skill transaction roots
* fix(settings): keep automatic skill updates non-destructive
* fix(settings): report committed skill saves accurately
* fix(settings): keep skill updates non-destructive
* fix(settings): route skill removals through selection
* fix(settings): close skill selection edge cases
* fix(settings): preserve skill data during maintenance
* fix(settings): hide unavailable skill install
* fix(desktop): keep install policy at command boundary
* fix(desktop): restore staged skill directories atomically
* test(app): retry transient skills sandbox cleanup
* fix(desktop): recover externally deleted skill stages
* fix(desktop): preserve conflicting skill rollback data
* fix(desktop): generalize skill rollback conflicts
* fix(desktop): preserve skill rollback collisions
* fix(desktop): recover quarantined skill backups
* fix(app): inset adaptive sheet content on compact
The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.
AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.
The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.
Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(desktop): prevent skill rollback data loss
Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Restore the Linux startup behavior that shipped in 0.2.3 by reverting the generalized sandbox detection from #2439.
Co-authored-by: tang <tang@tang-zai>