The Nix grammar package imports @codemirror/autocomplete at the top level
for its keyword snippets, but only declares it as a peer dependency.
electron-builder packs node_modules by walking declared production
dependencies, so it omitted the package from app.asar and the desktop
daemon died on startup with ERR_MODULE_NOT_FOUND. Dev and CI never saw it
because the workspace hoists everything.
This is the same failure that just shipped for Svelte, and the previous
fix removed @codemirror/autocomplete on the assumption Svelte was its only
consumer. Vendoring both remaining grammars removes the class of bug
rather than the current instance: these packages are interactive editor
extensions, so importing one drags a DOM editor runtime into a headless
token highlighter.
The vendored language modules keep upstream's indent and fold props. The
web file editor consumes getLanguageForFile().extension, so dropping them
would have silently degraded editing for .nix and .cs.
The new dependency-closure test replicates electron-builder's traversal
statically and would have failed both PRs. It is scoped to
@getpaseo/highlight because that tree is small and pure; the same walk
over the server tree is dominated by false positives from optional
dependencies loaded behind try/catch.
A steer pushed into the live SDK query input survived query.interrupt():
the SDK dequeued it afterwards and resumed the turn the user had just
stopped. Codex clears pending turn input when it aborts a turn; Claude
does not, so its adapter now cancels the SDK messages it queued before
interrupting.
Interrupt-discards is the deliberate ceiling for steer cancellation:
Codex exposes no un-steer operation, so a cross-provider take-back
cannot exist. A steer the model never read keeps its transcript row.
A resumed native child can be announced under a fresh tool call while retaining its session-scoped task identity. Treat later tool-call identifiers as aliases so lifecycle, usage, and timeline events continue through the original descriptor instead of splitting into contradictory rows.
* 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>
A host can refuse a timeline read for as long as it likes. Codex does
this whenever another writer holds the thread: every catch-up fails with
"already has an active writer" until the other session lets go. The app
retried that on a fixed 1s timer with no ceiling, so an idle window sat
there issuing a request and logging a warning every second, indefinitely.
The refusal is normal lifecycle; the traffic was the bug.
Catch-up and subscription retries now double from 1s to a 30s ceiling,
resetting on success, reconnect, delivery-mode change, and visibility
change so recovery stays immediate once the condition clears.
Backing off alone would leave the user waiting up to 30s with no way in,
so both failure surfaces get a retry they can press. Background retries
stay silent; a retry the user asked for is a fallible user action and
owns its pending state, so "retrying" is a status the sync model
publishes rather than a React boolean.
The blocking "Failed to load agent" screen was the worse half: its load
effect self-gates on the error state, so a refused first resume was
terminal until the user navigated away. Its retry resets the state to
idle, which re-arms the effect.
* fix(app): preserve IME composition in form inputs
Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.
* refactor(app): centralize text editing authority
Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.
* fix(app): restore composer placeholder styling
Headless hosts only have ~/.config/cursor/auth.json from a CLI login,
not the desktop state.vscdb token. Without that fallback Settings usage
stayed unavailable on VPS-style machines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* 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.
Wide layouts make tabs from the tab strip's + menu, so the header menu
listing new agent / new terminal / new browser duplicated it. Compact has
no tab strip, so those actions have to live in the header there.
One menu with an isMobile prop could not express that: the prop only sized
the trigger, and the item list was shared. Two components now, with the
workspace actions (import, copy path, copy branch name, show setup) in one
component both render from the same callbacks so they cannot drift.
Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail.
Track pills became menu surfaces when the trackers moved out of stacked
cards, but ComposerTrackRow kept the raw Pressable it had as an inline
card row. Nothing in that path reaches selectItem, so the popover or
sheet stayed parked over the transcript after opening a subagent, and on
iOS the action fired while UIKit was still tearing the sheet down. Rows
now hand the press to the engine and take closeOnSelect, as MenuItem
does; bulk archive opts out because its progress lands in the panel.
The running mark is the shared StatusRing rather than a still dot the
pill drew for itself, and the remaining dots read their colour from the
one bucket-to-colour map. The slot is sized for the ring in every state
so the label does not step sideways when the last child finishes.
Mounting the real menu engine in a test needed harness work: stubs for
two packages that ship untranspiled Flow, reanimated left out of Vite's
prebundle so its .web files win, and the gaps filled in the unistyles
test theme.
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
The selected segment was an inverted fill — theme foreground behind
surface0 text — which in dark mode dropped a white slab into thin
chrome like the file toolbar, and the fully rounded corners made it
read as a lozenge rather than a tab. It now uses a surface3 fill with
foreground text, and each segment takes the corner radius of the
button at its size tier.
Segments also drop one padding step below the equivalent button. The
gap between segments already supplies the visual padding, so matching
button padding made the track wider than it needed to be and ate the
breathing room around it in a 32px toolbar row.
Selection changes colour only. No weight or size change, so labels
never reflow as you switch segments.
Carries the buttonControlHeight extraction that was already sitting in
control-geometry.ts, since the two files could not be split apart
cleanly for this commit. Its callers stay uncommitted.
* feat(plugins): add workspace panels and command center contributions
Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs.
* fix(plugins): align shared client state ownership
Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature.
System-injected prompts never enter the Paseo timeline, but their provider runs keep distinct canonical turn IDs. Keep those IDs for lifecycle correctness while grouping adjacent output without a visible prompt into one response.
The subagents track and the task list were stacked cards above the
composer. Each one pushed the composer further down the pane and painted
an opaque band the timeline stopped at, and both spent a full row on a
count that fits in a pill.
Both are now pills that open a panel — a popover on wide screens, a sheet
on compact ones, from the existing menu engine rather than a fourth
floating-panel implementation. The row frame is shared, so a subagent row
and a task row sit on the same rail whatever their contents.
The bar mounts inside the transcript's animated container and pins itself
to the bottom edge, so scrolled content passes under the pills instead of
stopping above them. It stays a child of that container rather than a
sibling floating over the composer: on Android a view outside its parent's
bounds paints but takes no touches, and every pill is pressable. The
container carries the same keyboard transform the composer does, so the
pills track the composer's top edge when the keyboard moves.
The tracker state moves out of the composer with it. None of it was
composer state; it lived there because that is where it used to render.
* feat(cli): manage projects
Expose the existing project lifecycle through one CLI command group so projects can be managed without opening the app.
* fix(cli): preserve remote project paths
Project paths belong to the daemon when a target is explicit. Require that path instead of resolving the CLI machine's cwd, and keep local convenience resolution for the default connection.
* docs(cli): document project management
* docs(cli): remove repeated project reference
* perf(app): keep composer typing responsive
The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character.
* fix(app): preserve live composer state at actions
* fix(app): use live input for autocomplete clicks
* fix(app): keep submit activity atomic
Use the shared interface text tier across the workspace header, conversation, and composer so the working surface no longer shifts between adjacent font sizes.
* feat(agents): add active-turn steering
Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side.
* fix(agents): stabilize steering history and replay QA
* fix(agents): preserve steering timeline order
* fix(agents): reconcile resumed provider history
* fix(agents): serialize steering with cancellation
* test(agents): prove Claude multi-tool steering
* test(agents): force explicit history hydration
* fix(agents): flush output before steer admission
* feat(plugins): expose Paseo API to local plugins
Plugin surfaces borrow the selected host connection while subprocess handlers receive an exclusively owned IPC session, so plugins can use normal workspace and agent operations without inventing another transport.
* feat(plugins): reload global enablement safely
Treat the global plugin switch as a live daemon setting and teach plugin automation to inspect it before acting. Enabling remains permission-gated because plugin backend code can access the daemon machine and client contributions execute inside the app.
* feat(plugins): expose plugin logs
Capture plugin stdout and stderr separately from IPC so authors can debug initialization, RPC, cleanup, and crash behavior from the app or CLI. Keep a bounded tail across lifecycle recovery while preserving structured output in the daemon log.
* fix(build): order plugin dependency builds
* test(plugins): align startup and CLI expectations
* perf(app): reduce warm workspace switch work
Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock.
* perf(app): accelerate retained workspace switching
Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks.
* perf(app): defer composer focus during workspace switches
* perf(app): retain recently used workspaces longer
Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used.
* fix(app): reconcile retained timeline geometry
* fix(app): refresh retained reading position
* feat(daemon): reload configuration without restarting
Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.
* fix(daemon): preserve reload resource ownership
Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
* feat(app): archive finished subagents together
Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.
* test(app): clarify subagent archive assertions
* feat(plugins): add local plugin surfaces and RPCs
Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.
* feat(plugins): add Linear attachment source
* fix(nix): refresh npm dependency hash
* test(server): exclude internal sessions from Hub counts
* test(server): distinguish Hub execution sessions
* feat(plugins): make local plugins explicitly opt-in
Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.
* refactor(plugins): establish host boundaries
* fix(nix): refresh plugin dependency hash
* fix(plugins): complete packaging and startup rollback
* feat(plugins): add managed local lifecycle
Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.
* fix(nix): refresh plugin dependency hash
* perf(timeline): resume cached history from its exact range
Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.
* test(timeline): hide replica setup behind E2E helper
* fix(timeline): bound persisted resume overflow
* test(timeline): cover workspace eviction catch-up order
* test(timeline): read persisted range from IndexedDB
* feat(sync): restore host directories before reconnect
Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.
* fix(sync): preserve active turn identity during catch-up
Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.
* test(e2e): inspect the real IndexedDB replica
Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.
* fix warm reconnect cache paths
* feat(sync): persist project icons and deepen directory sync
* fix(sync): preserve project update order
* fix(cache): preserve validated replica persistence
* fix(sync): retain cached agents during catch-up
Provider catalog discovery can exceed one minute during cold startup. Increase the default deadline while preserving explicit config and environment overrides.
* fix(server): trust healthy workspace git watchers
Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription.
* fix(server): refresh watcher ignores from git metadata
Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling.
* test(server): drive ignore updates through watcher events
The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit.
* fix(server): normalize git metadata paths on windows
File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too.
* fix(server): trail concurrent ignore refreshes
Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current.
* fix(providers): make catalog refresh deadlines configurable
Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background.
* fix(opencode): abort catalog server acquisition
A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller.
* test(file-observer): allow native burst recovery
* fix(hub): make daemon disconnect unilateral
Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately.
* fix(hub): preserve disconnect intent across crashes
Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop.
* fix(worktrees): preserve selected base config
New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed.
* fix(worktrees): seed config exclusively
* fix(projects): refresh config status on focus
* fix(subagents): preserve opened children on parent archive
Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.
* fix(subagents): mark generic tab openings
Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.
* fix(subagents): classify cold tab openings
Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.
* fix(app): translate navigation fallback errors
* fix(navigation): allow offline workspace intents
* refactor(app): derive subagent ownership from open tabs
* fix(server): serialize subagent lifecycle ownership
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.
* fix(worktrees): honor saved setup configuration
Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.
* fix(worktrees): replace config symlinks safely
A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.
* feat(cli): add workspace rename command
`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): harden workspace rename against read-back and arg failures
Review findings on the rename command:
- A failed workspace list sweep after a successful title change reported
the whole command as failed. The read-back is display-only, so it now
runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
CommandError, but DaemonRpcError always carries a `code` property even
when it is undefined, so the guard let those through to render as
UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
renamed to its first word and dropped the rest. The command now rejects
excess arguments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(cli): report the rename RPC response instead of reading back
Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.
Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.
`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Search models across all providers from the picker root
* Store agent profiles in daemon config and apply agent config in one RPC
An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.
Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.
Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.
list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.
* Add the agent profiles settings section
Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.
The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.
The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.
* Document agent profiles and name the four senses of profile
The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.
data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.
* Pin agent profiles to the model picker and delete favourites
Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.
A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.
Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.
Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.
* Cover agent profiles and cross-provider search with E2E specs
Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.
The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.
* State what applying a config bundle does on partial failure
applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.
Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.
What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.
* feat(agent-profiles): refine profile discovery and appearance
Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.
* fix(agent-profiles): defer profile swatch styles
Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.
- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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(agents): preserve permission notification callbacks
Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.
* fix(agents): notify concurrent permission requests
* refactor(storage): remove chat rooms and agent loops
This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.
* test(cli): remove retired chat and loop coverage