Commit Graph

16 Commits

Author SHA1 Message Date
Peter Steinberger 9b43f1c82e improve(ui): fix sidebar session row hierarchy and add a message-preview toggle
Pinned sessions inherited the nav zone's muted colour on the session *title*, so a
pinned row's preview line outshone its own name and the same session read dimmer
pinned than unpinned. A title is content in every zone; only glyphs, meta and the
hover fill follow the zone. The subtitle drops to plain --muted, already proven AA
against every surface by theme-contrast.test.ts.

Rows with no preview text reserved a fixed 18px second line anyway, leaving a dead
band or a lone spinner hanging below-right of the title. They now collapse to one
line via a --single-line class plus a CSS variant, so the endcap rides beside the
title. Rows also gained a little vertical air and reclaimed the right-hand gutter
inside the row, leaving the deliberate scrollbar clearance from #124879 intact.

Adds a localStorage-backed "Show message preview" toggle to the session sort menu
(default on). Operator-actionable state is exempt: attention, the queued
concurrency-slot explanation, and critical observer headlines (stuck /
waiting-on-user) always show, because hiding them behind a display preference is
the silent-failure class.

Also registers extensions/codex/src/app-server/run-attempt-tools.test.ts, which
arrived in #126189 with no shard claim and left main red.
2026-08-19 00:08:07 -07:00
Jesse Merhi a928da457f Show worktree option only for Git group folders (#125280)
* feat(ui): show worktrees only for Git group folders

* feat(ui): enrich group environment picker

* fix(ui): preserve worktree defaults on probe errors

* fix(ui): reject stale group repository probes

* test(ui): type repository inspection helper
2026-08-17 19:50:15 -07:00
Peter Steinberger b5b17c654b fix: keep Workboard visible in the sidebar (#125473)
* fix(ui): keep workboard in sidebar navigation

* test(ui): align workboard navigation ownership

* fix(ui): preserve saved workboard sidebar slots

* test(ui): shrink assertion safety baseline

* fix(plugins): bind native routes to bundled owners

* refactor(plugins): isolate native route policy
2026-08-17 18:44:39 -07:00
Peter Steinberger 55ce95fac8 feat(sessions): three-layer session ownership — agent attribution, assignable owner, participants (#125057)
* feat(sessions): stamp agent identity on spawned sessions and return spawn receipts

Agent-spawned sessions recorded the requesting session key as createdActor.id,
so the Control UI creator chip rendered an opaque key. Spawn producers now stamp
the canonical requester agent id; parent-authority validation moves to a new
trusted requesterSessionKey field. projectSessionActor enriches agent actors
with configured identity name/avatar at read time, and visible sessions_spawn
returns a sessionUrl + owner receipt with URL-first acknowledgement guidance.

* feat(sessions): assignable session ownership with owner facet and menus

GitHub-assignee-style ownership: sessions get a mutable owner (defaulting to
the immutable createdActor) stored in additive bare-nullable SQLite columns
with first-use lazy ensure. New operator.write sessions.assignOwner validates
targets, requires an identified caller, authorizes by session visibility, and
records assignedBy/assignedAt inside the write transaction. The sessions agent
tool gains assign_owner; the Control UI adds Assign-to-me/Assign-to menus in
sidebar rows and chat headers, renders the effective owner chip, and the
creator facet/filter now keys on effective owner. Sharing authority stays
anchored on createdActor.

* feat(sessions): record session participants and stack them in the owner chip

Records every distinct external prompter (human profile/channel sender, or a
requesting agent) per session in an additive session_participants table at the
turn-admission boundary — best-effort, deferred, never blocking the turn; the
session's own agent and viewers are never recorded, capped at 32 per session.
The session row projects a bounded participants list (owner excluded) plus a
total count with the same actor enrichment as owner/createdActor. The sidebar
chip becomes a pair-stack when others have prompted (owner front, one peeking
participant or +N behind), the chat header shows the full facepile, and an
authenticated involvingMe list filter adds an Involving-me sidebar predicate.
Participant projection is excluded from logical-session CAS equality so display
history never invalidates session writes.

* fix(sessions): identify built-in agent tool callers for owner assignment

The sessions tool's assign_owner dispatched through the in-process synthetic
client, which carries neither a signed agent-runtime identity nor a human
profile, so agent-initiated reassignment always failed with FORBIDDEN. The
tool now captures its trusted requester agent identity and carries it across
in-process dispatch as internal client state (never wire params); the handler
derives assignedBy as signed runtime identity, then trusted agent-tool caller,
then authenticated human. Live-verified end-to-end on a dev gateway.

* fix(ci): split oversized session modules and refresh prompt snapshots

Split the max-lines offenders at concept boundaries for session equality, tool overrides, and protocol owner schemas. Remove the redundant Number conversion from the node:sqlite participant count. Refresh prompt snapshots after drift from the sessions and sessions_spawn tool description updates.

* fix(ci): restore solo-mode chip suppression and conform new method descriptors

Solo-mode root cause: owner-assignment submenu options reused the permanent owner-chip custom element, so hidden menu avatars were counted as attribution chrome. Menus now use viewer avatars while gateway-gated owner chips remain exclusive to collaborative sessions.

Conform sessions.assignOwner to the 2026.8 descriptor and append-only advertised-method inventories, and regenerate the Swift and Kotlin protocol surfaces.

Keep historical v15/v14 fixtures frozen by stripping the new owner columns; the existing range already excludes the participant table. Replace the new raw SQLite schema probes with synchronous Kysely queries.

Clear max-lines by splitting the organizer host contract, pure agent-navigation projections, and ownership/filtering sidebar cases at their concept boundaries.

* fix(ci): integrate ownership series with latest main surfaces

Wire the sessions-page assign-owner action, merge capability imports, narrow the navigation export scope, and apply sessions-create formatting.

The owner-presence regression came from hidden assign-owner menu avatars emitting data-viewer-id, so owner and menu chrome now opt out of presence markers while real facepiles retain them.

* fix(sessions): scope the involving-me filter to profile-backed participants

Session participant history mixed channel-native sender ids with authenticated Gateway profile ids, so involving-me missed real sessions and could accept numeric collisions.

Record the actor_source namespace at each producer, carry it through the internal SQLite projection, and match authenticated viewers only against profile-backed human participants. Legacy NULL sources fail closed for filtering, while channel ids remain available for display.

* build(ui): raise startup budget baseline for session ownership surfaces

Ownership chips, assignment menus, and the participant stack add ~0.7 KiB
gzip to the startup path; CI compression landed just over the previous
baseline+tolerance. Hard cap (350 KiB) unchanged.

* refactor(sessions): drop raw NULL projection for the lazy actor_source column

The Kysely guardrail rejects typed raw sql snippets outside allowlisted
boundaries; select the lazily-ensured column only when present and let the
row projection treat its absence as unknown/legacy.

* build(ui): refresh combined startup baseline
2026-08-17 02:35:20 -07:00
Sebastien Tardif bf4ec4b58a fix(ui): disambiguate multi-account session labels (#124228)
* fix(ui): disambiguate multi-account session labels

Parse the optional account segment on per-account-channel-peer DM and
group keys so Control UI no longer falls through to the raw routing
key. Append a non-default account as " · accountId" on label,
displayName, and fallback so two Alice rows from different Telegram
accounts stay distinguishable.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix(ui): take the session account from the gateway row

The display resolver was reading the account out of the session key and
inferring it from label text. That missed the shipped `dm` spelling of
direct keys, invented an account-qualified group shape no key builder
emits, and treated any name ending in "(<account>)" as already
disambiguated.

The Gateway already projects the canonical `accountId` onto every session
row from the delivery route, so display consumes that and parses the key
only for panes rendered before their row arrives. Key parsing now covers
both `direct` and the pre-#11881 `dm` spelling, group keys go back to
their canonical account-less shape, and the account suffix is applied at
a single point instead of five.

`resolveChannelSessionInfo` classifies `dm` keys as channel sessions and
no longer reads a peer kind sitting in the channel slot as a channel.

* fix(ui): stop inferring session identity from rendered text

Three related text-inference defects in the same display neighborhood:

The account suffix was skipped when the resolved name already ended in
" · <account>". That reads rendered output as if it were a fact, and it
cannot tell an account apart from a user label that happens to look like
one. The suffix is now appended from the recorded account alone.

The string it was defending against came from the sidebar rename dialog,
which pre-filled with the resolved display name. Submitting persisted a
derived string as a real user label, which then outranked every later
derivation. Sidebar rows now carry the stored label alongside the
resolved one, and rename edits that, matching the Sessions page.

Channel classification accepted an account segment before any peer kind,
so `agent:<x>:<channel>:<account>:group:<id>` was filed under a channel
even though accounts qualify direct chats only and the canonical route
parser rejects the shape. The grammar now allows an account only before
`direct`/`dm`.

Resolver tests now prove the row projection is authoritative rather than
duplicating an account the key already carries: one case where only the
projection supplies it, and one where the two disagree.

* ci: retrigger after check-lint shard cancel

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* test(ui): prove rename opens on the stored label

The existing sidebar rename coverage uses a row that already has a stored
label, so it passes whether rename pre-fills the stored label or the
resolved display name. It could not catch the round-trip this branch
repaired.

Adds a browser case on a displayName-only row with a projected account:
the sidebar shows "Alice · cards" and the rename field opens blank. On
the parent commit the field opens holding "Alice · cards", which is the
string that used to become a real user label on submit.

* fix(ui): do not double-append a stored account suffix

A persisted label that already ends in " · accountId" must stay as-is
so "Alice · cards" does not become "Alice · cards · cards".

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* docs(ui): name the producer the account suffix check defends

The comment above withAccountDisambiguator claimed the rendered name is
never inspected, which the suffix check contradicts. State the real
contract instead: the account itself still comes only from the recorded
value, and the endsWith check is idempotence against the chat pane's
inline rename, which seeds its input with the rendered title and
persists a partial edit verbatim.

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-08-15 20:37:51 -03:00
Jesse Merhi edb5adfbf5 Start new sessions with folder group defaults (#123276)
* feat(ui): add folder group session defaults

* fix(ci): align folder group contracts

* fix(protocol): refresh Android gateway methods

* fix(ui): reuse folder picker for group defaults

* fix(ui): harden session group defaults

* test(ui): align group defaults with current main

* test(ui): keep group catalog defaults path-free

* fix(ui): close folder group CI gaps

* fix(ui): satisfy folder group CI contracts

* fix(session-groups): enforce defaults safety

* test(gateway): keep group defaults in workspace

* fix(session-groups): enforce defaults participation

* fix(session-groups): close review authorization races

* fix(session-groups): canonicalize defaults authorization

---------

Co-authored-by: Jesse Merhi <jesse-merhi@users.noreply.github.com>
Co-authored-by: Jesse Merhi <openclaw@users.noreply.github.com>
2026-08-15 15:24:13 +10:00
Vyctor H. Brzezowski 89df45c0f5 fix(ui): make DONE moves visibly reversible (#122303)
* fix(ui): make group category moves reversible

* fix(ui): address native Groups drag review
2026-08-11 19:39:07 -03:00
Vyctor H. Brzezowski 3dc03f9265 fix(ui): confirm destructive sidebar session actions in-app (#121286)
* 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.
2026-08-11 02:15:05 -03:00
Ayaan Zaidi 45ec5f4662 feat(ui): hide system-created probe sessions from the sessions sidebar (#121855)
Machine-created probe/run sessions (health checks, internal effect sessions) previously surfaced in the Control UI sessions sidebar as raw first-message rows and buried real conversations. Classify them from recorded creation provenance only (system actor, or unnamed run/internal creation without a human actor) and hide them by default behind a persisted "Show system sessions" toggle, mirroring the automation-sessions toggle. Cron rows stay owned by the automation toggle; the selected session, the Sessions page, and the toggle keep hidden rows reachable. Legacy rows without provenance stay visible.

Closes #121851

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:53:15 +00:00
Vyctor H. Brzezowski b953cb2597 fix(ui): rename and delete session groups without browser prompts (#121738)
* 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.
2026-08-10 23:27:39 -03:00
Vyctor H. Brzezowski 20d928e3c3 fix(ui): name new session groups in an owned dialog instead of a browser prompt (#121249)
* 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.
2026-08-10 17:16:16 -03:00
Patrick Erichsen ce133d4a35 fix(ui): use in-app dialog for session rename (#121255) 2026-08-09 15:24:02 -07:00
Peter Steinberger 876a3f0d8f fix(ui): bulk session archive no longer stalls per thread (#120493)
* perf(ui): batch session archive requests

* fix(ci): sync bulk archive protocol surfaces

* fix(plugins): validate bulk archive ownership

* fix(ci): restore current main quality gates

* fix(ui): bound bulk archive dispatch

* fix(ui): bound bulk archive dispatch

* fix(ui): preserve bulk archive compatibility

* fix(ui): recover metadata-less archive fallback
2026-08-08 07:53:43 -07:00
Peter Steinberger 2de58d408a Control UI: gateway-owned sidebar section order (#113930)
* feat(ui): gateway-owned sidebar section order with hover-only drag grips

* fix(ui): repair sidebar section order CI gates

* fix(protocol): keep sectionOrder optional for older gateways

Native Swift clients ship separately from gateways, so new clients must decode older gateway responses that omit sectionOrder.

* fix(ui): reconcile gateway section ordering with main

* fix(state): allow lazy tables in v5 maintenance

* refactor(ui): retire prefs session section order in favor of gateway-owned order

Supersedes #113948 by deleting the unreleased ui.prefs.sessionSectionOrder key and its browser plumbing. Gateway SQLite sidebar_sections remains the single canonical store.
2026-07-26 01:15:19 -04:00
Peter Steinberger f90cef67c8 feat(ui): drag custom sidebar groups between built-in session zones (#113948)
* feat(ui): drag custom sidebar groups between built-in session zones

* test(ui): remove stale split ratio fixture

* perf(ui): raise startup budget baseline for session-section ordering pref
2026-07-25 19:11:25 -07:00
Peter Steinberger b74f04ada8 refactor(ui): move sidebar mutations and groups into one organizer controller (#112923)
* refactor(ui): move sidebar mutations and groups into controllers

* refactor(ui): lazy-load session organizer operations

* fix(ui): order organizer mutation scope before options
2026-07-23 01:37:18 -04:00