A pinned session led with a generic message-bubble glyph that no other
session row gets. renderSessionLeadingState fills each row's single
leading slot from a priority ladder, and the pinned branch outranked the
creator avatar chip, so a pinned row could never show who created it and
carried a glyph whose only claim was "this is a session".
Drop the pinned branch and its styling. Pinned rows already render
through the same renderSessionTree anatomy as the session list, so they
now fall through the same ladder as any other row: attention icon, owner
avatar, or nothing. The leading slot stays width-reserved, so titles do
not shift.
* fix(ui): confirm destructive sidebar session actions in-app
The sessions sidebar still gated batch delete, single delete, cloud-worker
stop, and preserved-worktree removal on native window.confirm. In an
embedded WebView without a dialog bridge that call resolves false, so each
action returned early with no request, no error, and no visible outcome.
PR #118250 introduced showConfirmDialog and converted the Sessions,
Worktrees, and Nodes pages but not the sidebar operations layer, leaving
one product action with two confirmation mechanisms. Route all four sites
through the same helper and let the already-present post-decision scope
guards do their job, extracting the cloud-worker reclaim guard above the
await so the modal never opens for a stop this surface never performs.
Closes#121275
* feat(ui): let operators opt out of the session delete confirm
Session deletes are the repeatable, per-row destructive action in the
sidebar, so their confirm now offers "Don't ask me again". Stopping a cloud
worker and removing a preserved worktree deliberately get no opt-out: the
first is a rare shared-resource action, the second destroys the only copy
of uncommitted work. The checkbox exists only for callers that pass a skip
preference, so the serious confirms stay unskippable by construction.
The preference is device-local in UiSettings rather than a synced ui.prefs
key, so opting out on one browser cannot lower the bar on the operator's
other devices. Appearance -> Sidebar carries the matching toggle with the
standard reset affordance, which is how asking gets turned back on.
* fix(ui): scope the delete opt-out copy and refresh its settings view
Autoreview surfaced two preference-consistency defects.
The setting copy promised a general "session or a selection" policy, but
only the sidebar honours it; the Sessions page delete paths still prompt.
Extending the preference there would cross into another owner's in-flight
surface, so narrow the copy to what actually ships and keep the extension
as the recorded follow-up.
Persisting the opt-out also wrote local storage without notifying the
appearance subscription, so a mounted Settings -> Appearance kept showing
the toggle enabled while deletes already skipped the prompt. Publish the
refresh through the scope's theme capability after persisting.
* fix(ui): space the confirm opt-out away from the message copy
The checkbox rendered flush under the message with no separation, so it
read as a third line of the paragraph rather than a distinct control, and
the group crowded the action row.
Give it the 12px separation the details block already uses in this card,
keeping the 16px action gap below, plus the 10px control gap and 16px box
the config-form checkboxes use so the box and label align. Verified in
light and dark at desktop and mobile widths.
* fix(ui): use the cursor-action token on the confirm opt-out
The spacing fix hardcoded cursor: pointer, which the Control UI cursor
policy forbids outside link rules; controls consume var(--cursor-action)
so the hand stays configurable from one place.
Caught by ui/src/styles/cursor-policy.node.test.ts. Token resolves to the
same hand, so the published dialog captures are unaffected.
* fix(ui): keep the delete opt-out to the surface its setting names
deleteSession is shared: the chat-pane header menu calls it too. The
skip preference was applied inside the operation, so opting out from the
sidebar also silenced future header confirmations while the setting text
promised it applied to sidebar deletes only.
Make the opt-out opt-in per caller with a default that keeps asking, and
let the sidebar be the one caller that offers it. The header and any
future caller now match the copy without having to remember anything.
* refactor(ui): split the session group catalog out of the operations runtime
Adding the in-app group confirm on main pushed
session-organizer-operations.runtime.ts past the 700-line ceiling once
this branch's session confirms landed beside it.
Move the catalog and section-order operations into their own module. They
write the group catalog directly and never touch session rows, so the
dependency runs one way and no import cycle appears. The shared access
gate moves to the batch-mutations sibling both now import, and the
runtime keeps re-exporting the catalog names the controller loads as one
namespace.
Pinned sessions shared the Pages navigation group's container and head,
so a pinned chat rendered as one more nav row directly under Automations
or Plugins with nothing separating elevated content from navigation.
Split the sidebar zone at render time: navigation entries stay in the
Pages list, session entries move into a second group with the sidebar's
inter-group spacing and a muted "Pinned" section label matching the
PAGES/SESSIONS treatment. Both groups keep the zone drop handlers, so
dragging a session into either one still pins it and records its slot,
and the group disappears entirely when nothing is pinned.
* fix(ui): rename and delete session groups in owned dialogs
The sidebar group menu was the last session surface still asking browser
chrome: Rename group opened window.prompt and Delete group opened
window.confirm. Both are unthemed, unvalidated and unavailable in the
webviews the Control UI also ships in, where they answer silently.
Rename now uses the owned input dialog, prefilled with the name it is
changing and titled with it. showInputDialog gains requireChange, which
holds submission closed while the entry still equals defaultValue, so the
no-op rename the caller used to discard after the fact is not submittable
in the first place. That replaces the caller-side "next === group" bail and
puts the empty check on one predicate shared by the button state and the
submit path.
Delete now uses the owned danger confirm, beside the other destructive
session mutations in the lazily loaded operations module, with Cancel
focused and no opt-out. Its copy said the sessions move to "Ungrouped", a
section id no operator ever sees; the list is labelled Sessions, so it now
says they move back to the session list. Deleting a group really does keep
them: session-groups clears the category on every member rather than
removing anything.
The confirm follows the access check so nobody is asked about a delete the
Gateway would refuse, and the mutation scope is reproven once they answer.
* test(ui): freeze animations in Control UI proof captures
A dialog capture taken during its fade-in shows a half-transparent card over
the page behind it, which proves nothing about the state it was taken for.
Playwright can settle running transitions before the shot.
* fix(ui): stop repeating the group name in the delete confirm
The title said Delete group and the message repeated it with the name, so
the operator read the same three words twice before reaching what actually
happens. The title now carries the group it is about and the message is
left to state the outcome.
* fix(ui): keep stale group deletes retryable
* fix(ui): match the stale delete notice to its sibling
The retry notice led with the negation and used curly quotes the rest of
this dialog family does not, so it read as a different product's copy. It
now states the cause and the next step the same way newGroupStale does for
the same replaced connection.
* fix(ui): name new session groups in an owned dialog
Sidebar Move to group -> New group and the Sessions page New group action collected the group name with window.prompt, so the only text-entry step in that flow was browser chrome: unthemed, unvalidated, and unable to keep the typed name when the create was rejected.
Adds showPromptDialog next to the existing showConfirmDialog helper and routes both new-group surfaces through it. createSessionGroup now reports its mutation result so a rejected create keeps the dialog and its value for a retry.
* fix(ui): keep the prompt dialog usable when its operation throws
A rejected submit left the field disabled and the module-level guard latched, so every later prompt in the session was dropped. Report the thrown error as the visible failure instead.
* refactor(ui): keep the prompt dialog options type module-local
Nothing outside prompt-dialog.ts consumes the options type, and the deadcode export gate rejects unused public surface.
* fix(ui): keep the new-group catalog write and assignment on one scope
rememberSessionCustomGroup discarded whether its connection was still current, so a groups.put that outlived its connection was followed by a sessions.patch issued on the replacement one. It now reports completed/failed/stale like the sidebar catalog write, and the Sessions page threads one captured scope through both writes.
* refactor(ui): name new session groups through the shared input dialog
input-dialog.ts already owns Control UI text entry, so the new-group flow no
longer ships a second near-identical dialog next to it. showInputDialog gains
two additive options instead:
- requireValue trims the entry and holds submission closed while it is blank,
replacing the ?.trim() bail every prompt call site repeated. Rename keeps it
off, because an empty rename still has to clear a custom label.
- submit runs the operation behind the dialog and keeps it open on failure with
the typed value intact, so a rejected create is correctable rather than
retyped. The Gateway rejects a group name over 512 characters, and that
message now reaches the operator without discarding what they wrote.
The input stays uncontrolled: its value binding is constant, so repaints for the
submit and failure states leave the caret and IME composition alone. The
AbortSignal contract and the reentrancy guard are unchanged.
Both catalog writes also honor the result groupsPut returns. The capability
retires a write on its own connection epoch, which the caller's scope predicate
cannot observe, so discarding it could file a session into a group no live
connection ever confirmed.
* fix(ui): survive a submit callback that throws before it returns
The catch was attached to the returned promise, so a non-async callback that
threw during synchronous validation escaped it: the rejection left submitting
latched, every control disabled, escape blocked, and the module-level guard held
for the rest of the session. The call now happens inside the try.
Also aligns the shared sidebar harness with the catalog contract. groupsPut
resolved undefined while its groupsRename and groupsDelete siblings already
resolved "completed", so the harness disagreed with the capability it stands in
for as soon as the caller started reading that result.
* fix(ui): do not recreate a session that vanished during the catalog write
Awaiting the group catalog write before the assignment opens a window in which
the target row can be deleted. sessions.patch creates a store entry for an
unknown key, so the assignment would resurrect the session the operator just
removed. assignCategory already guards its own patch this way; the new-group
path now does the same.
* test(ui): widen the empty session-list cast for the vanished-row case
* fix(ui): re-resolve sidebar group targets before assigning them
The new-group dialog no longer blocks, so the rows captured when the menu opened
can be deleted while the catalog write is in flight. sessions.patch creates a
store entry for an unknown key, so assigning them would resurrect the sessions
the operator just removed. The Sessions-page path already guards this; the
sidebar now re-resolves every target against the current list before patching.
* refactor(ui): load the input dialog behind one lazy boundary
input-dialog.ts was imported statically by the Sessions page and dynamically by
the sidebar controller. Mixing both for one module makes the dynamic import
ineffective and pulls the dialog into a startup chunk that never needs it until
an operator opens a menu. All four call sites now share the lazy boundary.
* fix(ui): keep a stale group submission open for retry
A Gateway connection replaced mid-write confirmed neither the group nor the
move, but both surfaces mapped that outcome to a silent close: the dialog
vanished and the typed name went with it, leaving nothing on screen to explain
why no group appeared. Both now report a retryable message so the entry stays
put and resubmitting runs against the replacement connection.
The row-vanished path still closes: there the group did land, and only the
assignment was skipped.
* fix(ui): close the input dialog when its owner goes away
The dialog mounts on document.body, so navigating away left it over the
destination with a detached owner, and a later submit ran against a page that
had already torn down its subscriptions. Both the Sessions page and the sidebar
controller now hand it a lifecycle AbortSignal and abort on disconnect, using
the option the component already accepted.
* fix(ui): prove the target session when a delayed patch lands
The new-group assignment guarded itself by asking whether the row was still in
the current list. That list is a bounded, filtered projection, so an ordinary
refresh that pages a row out of view read as a deletion and silently dropped a
legitimate move, while a row that was genuinely replaced still looked present.
Both surfaces now carry the identity captured when the operator acted, and the
Gateway decides: sessions-patch-engine compares expectedSessionId against the
stored entry and refuses a changed target, so a patch can neither land on a
successor session nor recreate one that is gone. The projection guards are
removed rather than kept alongside it.
SessionPatch and the sidebar patchMany targets carry the field, and
SidebarRecentSession keeps the sessionId its rows already had from the Gateway,
so every sidebar mutation the operator starts before a replacement is covered,
not just group creation.
* fix(ui): make the dialog's lazy boundary safe to await
Three races opened up when the dialog moved behind a dynamic import.
The Sessions page read the target's identity after awaiting the chunk, so a
refresh during a cold load handed back whichever row had replaced it and the
identity guard then approved the wrong session. The lookup now happens before
any await.
The lifecycle was armed only after the chunk resolved, so a sidebar that
disconnected mid-import left nothing for hostDisconnected to abort and the
dialog opened behind a dead host. The import now runs inside the lifecycle.
A rejected chunk load produced no dialog, no error and an unhandled rejection at
the void callers. Both surfaces now report it where they report their other
failures.
* test(ui): make the sidebar projection case prove the assignment
The case waited on a condition that was already true before the catalog write
landed, so it returned before the continuation reached patchSessions and its
negative assertions passed without exercising anything. It also still claimed
the old behaviour: rows leaving the projection now do not suppress the
assignment, because a bounded, filtered list is not evidence of deletion.
It now waits for the batch itself and asserts each target carries the identity
captured with its row, which is what lets the Gateway refuse a replaced target.
* test(ui): split the sidebar new-group cases out of interactions
Adding the projection case pushed interactions.ts past the 700-line ceiling.
The multi-select helpers move to multi-select-support.ts so both files share one
definition, and the two new-group dialog cases get their own case module beside
the other per-topic sidebar suites.
No behaviour change: 246 sidebar cases still pass, and interactions.ts drops to
well under the limit without a suppression.
* fix(ui): stop cancelling the sidebar dialog on a re-layout
The compact-viewport E2E caught this: at 420px the sidebar is dropped from the
DOM, which fired hostDisconnected and aborted the open dialog, so resizing the
window mid-edit closed it and discarded the typed name — the same silent loss
this PR set out to remove.
A sidebar detach is not the operator leaving. The dialog is a body-level modal
and outlives the sidebar's DOM position by design, so the controller no longer
tears it down. The Sessions page keeps its binding, where a page unmount really
is a navigation.
* style(ui): format the extracted sidebar multi-select helpers
* fix(ui): keep the live dialog abortable when a second open overlaps
Two fire-and-forget new-group actions during the lazy import both installed a
lifecycle controller. showInputDialog drops the reentrant request, but the
second call still cleared the field on its way out, so the dialog actually on
screen was left with nothing for disconnect to abort and survived navigation.
A second open now reuses the active controller instead of taking ownership, and
a regression case overlaps two opens then detaches the page.
* refactor(ui): defer session-identity plumbing to its own change
The new-group dialog work had grown a second, separable concern: threading the
identity of the row the operator acted on through SessionPatch, the sidebar
patchMany targets and SidebarRecentSession, so the Gateway could refuse a patch
whose session had been replaced. That contract is real and already enforced by
server-methods/sessions-patch-engine.ts, but it reaches every session mutation
the sidebar makes rather than group creation alone, and a rejected identity
still needs its own terminal outcome before it helps an operator. It belongs in
a change that can be judged on those terms.
Both surfaces return to the projection-presence guarantee this change shipped
first: captured rows are re-resolved against the current list and an assignment
whose row is gone is skipped, which is what keeps sessions.patch from recreating
a session the operator just deleted.
The sidebar case covering that keeps the waiting fix it gained meanwhile. It now
waits for the dialog to be removed, which happens only once the submit chain has
run, instead of for a condition that was already true before the catalog write
landed and let the negative assertions pass without exercising anything.
* fix(ui): say when a new group landed without its move
Both new-group paths skip the assignment when the captured row is no longer in
the current list, because sessions.patch would otherwise recreate a store entry
for a session the operator had removed. That guard was silent: the dialog closed
on the same "completed" a full success returns, so an operator whose list had
simply refreshed or paged got a new group, an unmoved session, and nothing that
accounted for the difference.
The list is a bounded, filtered projection, so a row leaving it is not proof the
session is gone. The skip stays — it is the safe choice without the target's
identity — but it now ends in a visible outcome. The Sessions page records the
partial result in its own error surface and closes; the sidebar raises a toast,
singular or plural with the rows the operator selected. Both are terminal rather
than retryable: the group already exists, so resubmitting the same name could
only fail.
A header-created group still starts empty with no notice, since nothing was
requested to move.
* fix(ui): tighten the skipped-move notice
The two-string singular/plural pair pushed the Control UI startup bundle past
its gzip ceiling: the catalog is loaded at startup, so long copy is paid for on
every page load, and the check failed by 41 bytes.
One string covers both surfaces and both counts. It still states the outcome and
the next step, which is what the notice is for, and it drops the count branch in
createSessionGroup along with the second key.
* test(ui): split the new-group case out of the groups e2e file
The groups e2e file crossed the 1000-line ceiling for test files once this
branch's new case landed on top of the growth main had already added, and a
max-lines suppression is not an option here.
The owned-dialog case moves to its own file beside it, matching the split the
sidebar cases already got. It keeps the same shared helpers, so the move is
mechanical, and it leaves the groups file with room for the cases that stay.
* fix(ui): report sidebar group moves that were skipped
Re-resolving the selection against the live list stopped a removed row from
being recreated, but only the all-removed case reached the operator. When part
of a multi-row selection left the list while the catalog write was in flight,
the survivors were patched and the call returned a plain success, so the dialog
closed with the group created, some sessions moved, and nothing saying the rest
were not.
The count comparison now covers the partial case: the surviving subset is still
patched, and whenever fewer rows resolve than were requested the skipped outcome
is named. It stays terminal, since the group already exists and retrying would
only recreate it.
The Sessions-page path takes a single optional key, so all-or-nothing is the
only shape it has and it already reports the skip; the sidebar is the surface
with a multi-row selection to partially satisfy.
* feat(ui): show unsent-draft pencil on sidebar session rows
Typed-but-unsent composer text now surfaces as a pencil badge on the
owning session's sidebar row (and Home row) once you switch away.
Draft persistence now notifies stored-outbox subscribers so the
indicator appears and clears live. The active session suppresses the
badge since its composer is already visible.
* chore: refresh merge ref for CI against current main
* chore: refresh merge ref against healed main
* fix(ui): notify draft indicator only on presence transitions
Unconditional notify on every draft persist let outbox-projection
subscribers re-persist a stale pane over a newer draft (chat-state
route-fallback invariant). The sidebar pencil only consumes presence,
so notify on empty/non-empty transitions only.
* feat(ui): add people session sorting
Offer People sorting in the Control UI session sidebar when the Gateway reports multiple session-sharing identities.
* fix(ui): restore sidebar translation dependency
* fix(ui): preserve session sort ordering contracts
* fix(ui): offer undo and a named recovery path when hiding a sidebar section
Hiding a session catalog section wrote the preference and returned nothing:
the section vanished, no undo was offered, and the only standing recovery
(Settings > Appearance > Sidebar) was never named anywhere near the action.
Announce the outcome at the producer. hideSessionCatalog now names the section
that disappeared, offers Undo, and carries the Settings path in the toast text
for after it times out. The hidden-catalog set gets one owner,
setStoredSessionCatalogHidden, so the sidebar, the undo, and the Settings
toggle all mutate the same authoritative read instead of each rebuilding the
set from its own copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): align the automation-sessions checkmark with its popover siblings
Every selection state in the sidebar sessions filter popover reads on the
trailing edge, but "Show automation sessions" used Web Awesome's
type="checkbox" checkmark, which its template renders before the label. The one
row in the menu that is a checkbox rather than a radio put its check on the
opposite side of the row from every neighbour.
Hide the leading part and render the shared session-menu__check in the details
slot, so the row matches GROUP BY / SORT BY / STATUS while Web Awesome keeps
owning the menuitemcheckbox role and aria-checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): stop toasts from ellipsing away their recovery instruction
app-toast__message was nowrap + ellipsis, so any message longer than one line
lost its tail. That is fine for a bare acknowledgement and wrong for a toast
that tells the operator how to undo or where to re-enable something: the part
that gets cut is exactly the part they need.
Wrap instead, clamped to three lines so a pathological string still cannot grow
the toast without bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(ui): make the hidden-catalog writer module-private
With setStoredSessionCatalogHidden owning every hide/show, the raw
storeHiddenSessionCatalogIds writer no longer has a caller outside its own
module and the deadcode:exports gate flagged it. Drop the export rather than
keep a second public way to write the same preference, and move the two tests
that used it onto the owner they are actually meant to exercise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): anchor toasts to the bottom-right corner
Toasts were centered over the content column, which on chat routes puts a
passive notification directly on top of the composer. Move the shared
.app-toast container to the trailing bottom corner, the standard desktop
placement for passive notifications.
Anchoring it there alone would trade one overlap for a worse one: at a 20px
inset the toast lands exactly on the composer's mic and send controls. Chat
routes therefore lift it above the composer via --app-toast-inset-bottom on
.shell--chat, which the toast host inherits as a child of .shell. Routes
without a composer keep the tight 20px corner.
Phone widths keep the full-width bottom idiom, and now get it properly: the
centered toast used to collapse to its content width (200px measured at 390px
viewport), so the explicit left/right insets also widen it to 366px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): drop the chat-route toast lift
The corner toast no longer clears the composer on chat routes. Overlapping
the composer region is the accepted trade for one placement rule on every
route, so the route-specific 140px lift and its custom property are gone and
every surface uses the same 20px safe-area corner inset. Wrapped toast text
balances its lines now that the message can run to two.
* fix(ui): open the sidebar recovery path from its toast
Naming "Settings > Appearance > Sidebar" in the hide toast still left the
operator to find it. The recovery sentence is now a link built from the
settings-search destination that already describes that block, so the toast
opens the re-enable list instead of describing where it lives. Toast messages
accept a template for that.
* revert: leave the automation-checkmark repair to its own PR
#121429 fixes the same Web Awesome checkmark alignment on its own, with
browser regression coverage this branch does not carry. Two PRs owning one
CSS part is worse than losing the drive-by, so this keeps to the hide and
recovery invariant.
This reverts commit 6b1438a5be4bb1c9dd4c2ce9cd7d0dd54e77cb00.
* fix(ui): let the hide outcome reach a phone operator
On a phone the sidebar only exists inside the navigation drawer, and that
drawer is a modal dialog: a toast raised behind it is both occluded and inert,
so hiding a section there stayed exactly as silent as before this branch.
Promoting the toast into the top layer only fixes the paint half — a modal
dialog inerts everything outside it, so Undo and the recovery link still take
no clicks. So the hide hands the drawer back to the shell first, through a
seam the sidebar can use for any outcome that belongs on the main surface.
It is a no-op wherever the drawer is not open.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): confirm disruptive Control UI updates before dispatch
Every Control UI update affordance dispatched `update.run` (or the macOS
bridge `start-update` message) on the first click, so a misclick could
install new code, restart the Gateway, and interrupt active work.
Add one canonical confirmation gate in the Control UI owner boundary and
route the sidebar/settings-sidebar/floating update card and the
Settings -> Updates row through it. The gate owns the copy, the versions,
the restart consequence, the action-specific confirm label, and the
choice between the macOS bridge and `update.run`, so the bridge fork no
longer lives in the card and no surface can drift.
CLI, API, automation, agent-driven paths, automatic update campaigns, and
the `update.run` contract are unchanged.
Addresses the Control UI portion of #117178.
* style(ui): apply oxfmt to update confirmation sources
* perf(ui): load the update confirmation lazily and share its impact copy
Keeps the Control UI startup bundle under its 317 KiB gzip ceiling: the
confirmation dialog only loads when an operator opens it.
* test(ui): drive the update confirmation in sidebar and shell wiring tests
* fix(ui): retain dashboards across view switches
Keep visited dashboard runtimes alive across Chat, Split, and Dashboard transitions, suspend hidden work, and avoid same-face persistence requests.
Refs #120639
* fix(ui): preserve session owner during route loads
Carry the last successful route match through pending loads so face-only navigation retains dashboard runtimes without stranding session or catalog switches.
Refs #120639
* fix(ui): preserve sandbox bridge while hidden
Suspend hidden board frame work without disposing the one-shot sandbox handshake, loaded document, or MessagePort.
Refs #120639
* fix(ui): forward sandbox readiness while hidden
Keep the constrained sandbox message channel alive during dashboard suspension so one-shot proxy readiness reaches the retained host without resuming widget work.
Refs #120639
* fix(ui): pause hidden plugin widgets
Propagate board activity through trusted plugin renderers so Workboard widgets suppress hidden refresh and mutation work, then refresh once without remounting.
Refs #120639
* feat(update): scheduled update campaigns with countdown, dev-channel auto-update, and Updates settings page
* fix(ui): satisfy update campaign deadcode checks
* feat(update): extend scheduled update campaigns
* test(ui): include Updates page in settings sidebar search expectations
* fix(update): end campaigns when apply fails
* fix(update): pin dev campaign applies to the announced commit
* test(ui): align update e2e fixtures
* fix(update): pin adopted dev updates to the announced commit
* docs(update): document automatic dev update campaigns
* fix(ui): gate automatic dev updates on git installs
* fix(gateway): scope detailed update metadata to read-capable clients
* fix(gateway): break post-attach madge cycle with a leaf client-lookup type
* chore(protocol): regenerate clients after rebase
* test(gateway): update method tail after rebase
* fix(update): preserve campaign target and hold state
* test(update): align adoption mocks and split handoff suite
* test(update): route split handoff suites on Windows
* refactor(ui): align sidebar session list on a single lead-slot rail
* fix(ui): keep sidebar group drag handle interactive
* fix(ui): keep catalog chevron visible on touch devices
Touch contexts have no hover, so branded catalog headers keep the chevron as their resting affordance (ClawSweeper P2 on #120473).
* fix(ui): move stale-client refresh notice into the sidebar update card
The 'Server updated — refresh for full capabilities' notice rendered as a
full-width callout at the top of <main>, where the fixed shell-chrome
controls (nav toggle / search, layout.css z-index 45) overlap its text.
Move the notice into the existing sidebar-update-card slot above the
identity/connectivity footer: refreshRequired takes precedence over
updateAvailable (a stale client cannot trust its own update metadata),
renders non-dismissible with a refresh icon and two-line text, and is
wired through the sidebar, settings sidebar, and collapsed-nav floating
card. The old top-of-main banner branch, its resolver helper, and the
orphaned i18n key are deleted.
* fix(ui): keep stale-client refresh visible during onboarding and clear collapsed chrome cluster
Address both accepted ClawSweeper P2 findings:
- restore the stale-client refresh action during onboarding
- keep the floating update card clear of the three-button collapsed chrome cluster
Show the official Pi provider mark beside Pi external-session catalog headings in the Control UI sidebar. Keep OpenCode sibling branding covered and preserve transcript navigation and paired-node view-only behavior.
Proof: focused sidebar tests, browser-driven external-catalog E2E, production Control UI build/performance checks, UI typecheck, formatting/style checks, source-blind behavior validation, and clean Codex autoreview. GitHub Actions was in a confirmed service outage; maintainer explicitly directed landing without Actions. The repository full local gate reached unrelated failures across Codex, launchd, CLI, plugin naming, QA, and tooling suites after build and check passed.
* fix(ui): preserve chat history and active run timing
* test(ui): prove reconnect preserves active timer
* fix(gateway): keep recovery fallback within budget
* test(gateway): keep chat abort coverage within lint budget
* fix(ui): keep native context menus out of the sidebar and make session catalogs hideable
Suppress the WKWebView default context menu across the sidebar while preserving editable inputs, and route agent cards and session-catalog headers to the existing menus. Persist hidden session sections in localStorage and expose live-synced Show controls under Settings → Appearance → Sidebar.
Co-authored-by: Codex <codex@openai.com>
* test(ui): serve mock session catalogs so the sidebar catalog sections are exercisable in dev:ui:mock
Advertises sessions.catalog.list and returns synthetic Codex/Claude Code
catalogs, enabling reproducible live proof of the catalog header context
menu and hide/restore preference flow.
Co-authored-by: Codex <codex@openai.com>
---------
Co-authored-by: Codex <codex@openai.com>