Skip to content

feat(workspace): ⌘/ focuses the sidebar search, and the rail shows shortcut tips (ent#621 follow-up) - #3249

Merged
vybe merged 27 commits into
devfrom
feature/ent621-search-key-and-hints
Oct 8, 2026
Merged

vybe merged 27 commits into
devfrom
feature/ent621-search-key-and-hints

Conversation

@trinity-ability

@trinity-ability trinity-ability commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Part of abilityai/trinity-enterprise#621 — a follow-up requested by the operator while trying the keys. Builds on #3219 and #3241 (both merged). Frontend and docs only.

What

  • ⌘/ (Ctrl+/) puts the cursor in the sidebar's "Search agents and chats" field and selects what is in it. Pressed again from the field, it hands the caret back to the message field. Below sm the sidebar is the drawer, so the key opens it and focuses that instance. The chord echoes the Dashboard's / filter key. The field shows it three ways: a key cap while empty and unfocused, title, and aria-keyshortcuts.
  • The shortcuts list moves to ⌥/ to free the chord, keeping the ⌘. / ⌥. pairing. ⌘K stays reserved and unbound.
  • Shortcut tips in the rail (PortalKeyTips.vue): a small panel pinned to the bottom of the open rail. Two rows of two (switch agent, switch chat, search, new chat) with the close and open-the-list controls stacked beside them, 80px tall. The collapsed strip carries one icon at its foot instead; the mobile sheet carries neither. Dismissible, remembered per browser (localStorage).
  • keyHintCompact packs a chord pair that differs only in its last key into one cap (⌥↑↓, Alt+Shift+↑↓), and falls back to keyHint for anything it cannot pack without losing something.
  • PortalKeyCap.vue: one <kbd> recipe for the key list, the tips and the search field.
  • Everything printed is derived from WORKSPACE_KEYMAP (KEY_TIPS -> keyTipRows); nothing is hand-typed.
  • Docs: docs/memory/requirements/core-agent.md §5.21a (AC-1 and AC-9 amended, AC-11 and AC-12 added, written before the code) and the chat-tabs feature flow.

Operator rulings carried

  • ⌘/ for search (operator, 2026-10-05).
  • The panel is small, closable, and two rows by two columns (operator, 2026-10-05).

Decisions made here, for the operator to overrule:

  • The shortcuts list went to ⌥/ rather than losing its chord.
  • With the rail collapsed there is an icon, not a floating panel over the conversation.
  • The panel's "all shortcuts" control is icon-only, which is what keeps it to two rows.
  • A closed panel has no "bring it back" control; the sidebar footer button still opens the full list.

Tests

  • Written first; 27 red before the first commit, 4 more red before the two-row layout.
  • workspaceKeymap.spec.js (the new chords, keyHintCompact, keyTipRows, the dismissal storage), workspaceKeymap.mount.spec.js (the search key in the mounted shell: column, drawer, return to the composer, suppression under a dialog, allowed during a call; the dismissal surviving a remount), portalKeyTips.mount.spec.js (the panel in all three rail forms), portalKeyHints.mount.spec.js (the search field's key cap and focusSearch).
  • Full frontend unit suite at head (merge-train validation, 2026-10-08): 284 of 284 files, 4957 tests.
  • Browser (Chromium, macOS, local stack): search focus, select and return; ⌥/; the panel button; dismissal across a reload; light and dark. The two-row layout was measured at the default rail width and at the narrowest (280px), with Mac labels and with Windows labels (platform faked, not a Windows machine): two rows, no wrapped key cap, no horizontal overflow. Windows labels shorten with an ellipsis at the narrowest width.

Not covered

  • Not tested: real Windows and Linux browsers, Safari, Firefox, non-US layouts (⌥/ on a layout where / is a shifted key), screen readers, a tablet with a hardware keyboard below sm.
  • The user docs (workspace.md, the Workspace FAQ) now say ⌥/ opens the key list and ⌘/ jumps to search; updated on this branch by the merge train.

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 22 commits October 5, 2026 17:05
…rinity-enterprise#784)

Replaces `landingThread` with one pure rule, `agentLanding`, that every door
which has to RESOLVE a landing calls. ent#523 landed you in the chat you were
most recently active in; most visits to an agent start new work, so resuming
cost two actions every time. The default is now a new, empty chat.

Nothing is minted by the landing: the helper is pure and returns a null
session, and the row is born on the first send (`newThread`, ent#451), so
repeated visits accumulate no empty chats. An unused Main is already kept out
of the chat list by `sidebarThreadsOf` — the AC 4 guard, still green.

`agentLanding` takes `lastOpenSessionId` as the seam ent#621's agent-switch
keys will pass, honoured only when it still names a live, unarchived chat of
this agent in the principal's own thread list — so a stale or forged id falls
back to the default rather than landing somewhere it should not (#3140 class).
No Map is built here; ent#621 adds the state and the handler.

`resolveAgentLanding` keeps its signature and delegates, so the `?agent=` deep
link and the sidebar row cannot drift. `forceNew` stays accepted for existing
`?new=1` links and is now redundant rather than wrong.

Shell wiring (`landOnAgent`, the focus gate) follows in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lityai/trinity-enterprise#784)

Wires the shell to the ent#784 rule and gates the composer focus.

`landOnAgent` is now synchronous. The awaited `ensureMainListed` is gone from
the landing path — the rule needs nothing from the network — and with it the
overtake race that awaited round trip required. The pinned Main is still
minted on the first visit; `watch(activeAgentName)` owns that promise (ent#523)
and no longer blocks the landing.

The landed chat KEEPS `/workspace/a/:name` rather than escaping to bare
`/workspace`, so a reload, a bookmark or a copied link still names the agent;
the first send replaces it with the thread's own URL as today. An idempotency
guard makes the second watcher fire (the thread list arriving) a no-op, so it
cannot remount an unsent chat and throw away what was being typed.

`landOnAgent` now asks `guardLeaveCall` BEFORE touching `activeAgentName`,
which feeds `convKey`. Back/forward and a typed `/workspace/a/:name` reach the
landing without passing a click door, so they could end a live voice call
without a word (the ent#551 class; the click doors were already guarded).

Focus is gated on WHY the fresh composer mounted, not on the pointer alone.
A gesture (New chat, ⌘J, the agent picker, switch-agent) passes `always` and
focuses on any pointer, keeping #2579 AC 2 on Android. A landing passes
`fine-pointer` and focuses only where that cannot summon an on-screen
keyboard — the "unprompted" the issue names. `shouldFocusOnRestore` is renamed
`shouldAutoFocusComposer`: two reasons now share the one rule.

Tests: the focus gate is proven by MOUNTING PortalConversation under a stubbed
`matchMedia` (#2918), asserting `document.activeElement`, with focus shown to
be elsewhere first. Reverting either the rule or the gate turns 10 cases red on
behaviour — session ids and activeElement, not source text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-enterprise#784)

Tiered docs for a feature change: the owning area file, the requirement, and
the feature flow.

- `architecture/workspace.md` — `agentLanding` is the one landing rule and what
  it now answers; the synchronous `landOnAgent`, the kept URL, the guard order,
  and the `focusOnMount` mode.
- `requirements/core-agent.md` — the ent#523 "One page" rule amended rather
  than rewritten: what reversed, and that landing mints no row.
- `feature-flows/workspace-agents-at-the-centre.md` — the rule table, the "a
  tab is not a landing" note (the strip is now the only way back), the two
  doors that RESOLVE vs the gesture doors that ASSERT, and a `## Changed by
  ent#784` section rather than edited history. Names the follow-up: backend
  adoption of an empty Main on `new_thread=True` is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ted chat (Abilityai/trinity-enterprise#784)

ent#784's landing stays on `/workspace/a/:name`, so the watcher that resolves
that route can now re-fire with the route unchanged — once per thread-list
refresh — for as long as the person stays there. `landOnAgent`'s idempotency
guard keys on `startingNewChat`, which the first send clears, so a refresh
settling in the window between `onSessionAdopted` and the asynchronous
`router.replace` nulled `pendingSession` and bumped `convGen`, remounting an
empty composer over the thread whose first reply was streaming (and handing the
`/c/:id` watcher a #3140 "chat isn't available" for the chat just created).

Guarded in the watcher instead: a list-only re-fire is a no-op, keyed on "the
route did not change on this fire" AND on having already landed this name. Both
clauses are load-bearing — the name alone swallows the cold deep link, whose
only fire IS the list arriving with the param already in place. Deliberately
NOT keyed on `pendingSession`: back/forward from `/workspace/c/:id` arrives
with a session set and must still land fresh (T4).

New behavioural pin `portalAgentLandingRemount.mount.spec.js` (shallow-mounts
Portal.vue, asserts the conversation's props and instance identity, not source
text): the adopted-session case went red before this change with
`sessionId: null`; the three regression cases — composing, same-agent
back/forward, cold deep link — were green before and stay green.

Also fixes `portalUnavailableTargets.mount.spec.js`, which still asserted
ent#523's landing (`/workspace/a/scout` → `/workspace/c/t1`) and was failing on
this branch; it now proves the page opened by the conversation on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…source-text pins (Abilityai/trinity-enterprise#784)

`landOnAgent` was pinned only by regexes over its own function body
(`workspaceNewChat.spec.js:181-211`), so nothing executed it: an inverted
guard, or the right lines in the wrong order, reads byte-identically to a
regex. The shell is mount-testable (`portalUnavailableTargets.mount.spec.js`),
so these assert STATE instead.

Decision #13 (ent#551 class) — back/forward reaches the landing without passing
a click door, and `activeAgentName` feeds `convKey`, so a live call must be
asked about BEFORE anything is written: mid-call, the dialog opens and the chat,
its props and its instance identity are untouched; confirming then performs the
landing it deferred; cancelling leaves call and chat exactly as they were.

T4 — one navigation to an agent page mounts exactly ONE conversation, however
often the thread list moves underneath it.

Mutation-verified, both directions: with `guardLeaveCall` removed from
`landOnAgent`, the two mid-call cases go red; with the T4 guards removed, the
composing and one-mint cases go red. The source-text pins are kept as
supplements (they still pin "no await / no ensureMainListed / no router.push"
inside the function, which state cannot see).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…inity-enterprise#784)

F1 of the operator's 2026-10-05 reopen. `agentLanding` always answered a new
chat, so a draft typed into an existing chat was not where opening the agent
landed — and the draft mark on the agent's row pointed at words that clicking
it could not get back to. `e2e/workspace-drafts.spec.js:92` and `:116` describe
the wanted behaviour and were red at c8af3d7.

The rule is now the ruling's precedence: a link naming a chat (never reaches
here), then the ent#621 `lastOpenSessionId` seam unchanged as the first arm,
then the agent's chat holding an unsent draft — newest `updatedAt`, with the
unsaved `new:<agent>` chat weighed on the same clock — then a new chat. A
`new:` winner is `sessionId: null`, which is where those words already live; a
thread winner opens that thread and its remount restores the draft.

The candidate set is the set that lights the sidebar's mark: `isDraftedThread`
is shared by `agentsWithDrafts` and the new `draftedLandingFor` rather than
copied, so "the mark means click here to continue" cannot drift — a room and an
archived thread are excluded on both sides. The drafts map is passed IN by both
doors (`landOnAgent`, `resolveAgentLanding`), never read inside the rule, so
one rule serves every door and stays pure.

`composerFocusMode` moves above the branch in `landOnAgent`: a landing on a
drafted chat is the same kind of arrival, and `openThread` does not touch that
ref, so a previous gesture's `always` would otherwise have made the next
landing focus on a touch device (T3(b)). The restore's own caret already shares
`shouldAutoFocusComposer`. The T4 remount guard is untouched: the watcher still
returns on a list-only re-fire, and the drafted-thread arm leaves the agent URL
through `openThread`, so the guard is never reached with a stale answer.

Tests: `portalDraftLanding.spec.js` (each arm, newest-wins both ways, the tie,
room/archived exclusion, and the property that every MARKED row is a landing
candidate) and `portalDraftLanding.mount.spec.js`, which replays both e2e flows
at the shell's seam — red at c8af3d7 on the e2e's own symptom, 2 of 3 failing
with `sessionId: null` where the draft was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-enterprise#784)

F2 of the operator's 2026-10-05 reopen: "opening an agent must not create a new
chat every time; if an empty chat with that agent already exists it is reused,
and at most one empty chat per agent exists at any time."

Arm 4 of the precedence, under the drafts arm and above a new chat.
`agentEmptyChat` is the read side: an unarchived, non-room thread of this agent
with no message sent — ent#523's own "unused Main" test, widened to any row
that fits it. Both fields are required because the two reads disagree: the
cross-agent batch omits `message_count` (so an absent count must not read as
used) while the per-agent read carries it (so a count of 2 must not read as
empty just because `last_message_at` is missing). Several empty rows — legacy
data — resolve Main first, then newest `created_at`, then id, so list order
never decides and two doors reading one list cannot reuse two different rows.

`ensureMainListed` is the write side, and the only place the "at most one" half
can be held: it is a GET that INSERTS (`list_sessions` → `ensure_main_session`),
so for an agent with an empty chat but no Main — legacy, since nothing mints a
non-Main empty row today — visiting it would add a SECOND empty chat and then
land on one of the two. It now returns early for that case. #2579's "the pinned
tab has to be there" still holds for every agent whose chats are all used,
which is the case it was about; that is pinned as its own mount case rather
than left to the comment. No backend change: nothing here asks the server for
anything it does not already do.

Three existing specs carried fixtures with neither message field, which under
the old rule meant "a thread exists" and under the ruling means "an empty chat
to reuse" — the assertion they were written for. Each is updated to a USED row
and the empty case is asserted separately, so none of them went green by
accident: `portalAgentsAtCentre` (the landing arms), `workspaceAgentLanding`
(the `?agent=` door, both id shapes) and `workspaceNewChat`, where `?new=1` is
load-bearing again — it is the one way past the whole precedence, so the two
answers now have to differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the caret (Abilityai/trinity-enterprise#784 review)

ent#784 made /workspace/a/:name the URL a landed new chat RESTS on. Three
things had only been true because nobody stayed there; each was reproduced
in a browser against the rebased branch.

- Clicking the row of the agent whose new chat is already on stage cleared
  `startingNewChat` in `openAgentPage` and then pushed the URL it was
  already on, so the landing never re-ran. The composer still read "New
  chat" and sent its first message without `new_thread`, which the server
  resolves to the agent's Main chat. The click is now a no-op that hands
  the caret back.
- The rail rules still read the agent URL as "a page, not a conversation",
  so a landed new chat had no rail, and the first send (which moves the URL
  to /workspace/c/:id) slid one in beside a reply mid-stream. The URL is
  rail-free only until the landing has put the named agent on stage, and
  its column is reserved mid-load like any 1:1 route.
- A landing that reuses a chat (the agent's empty one) goes through
  `openThread`, and a thread mount focuses nothing, so the caret stayed on
  the sidebar row. The shell hands it over under the landing's own
  fine-pointer rule.

Tests: portalAgentLandingStage.mount.spec.js (10 cases, mounted shell).
Four were red before the fix, for these reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rinity-enterprise#621)

Checkpoint A of ent#621: the declaration every Workspace key will be
dispatched from, with nothing wired to it yet (the shell dispatcher is B).

`portalKeymap.js` declares all nine shell chords — ⌘J, ⌘., ⌘/, ⌥↑/↓,
⌥⇧↑/↓, ⌥. — plus the reserved ⌘K (ent#577) and the six component-owned
protocol keys (Esc, the call's M, the preview's arrows, tab roving, a
dialog's Tab cycle, the typeahead's bare arrows). The protocol entries
are declared precisely because `keymapCollisions` is only worth anything
if it can see the whole surface: a shell chord that shadows Esc is the
2026-09-07 class, and a map listing only its own keys cannot tell you.

Matching is platform-free — `primary` is meta XOR ctrl everywhere (the
shipped ⌘J semantics) and a chord matches the printed `key` OR the
physical `code`. The two arms disagree about Shift on purpose: without
the `key` arm DE's `Shift+7` → `/` is dead, without the `code` arm `⌥.`
is dead on a US Mac (it types `≥`), and with Shift accepted on the
`code` arm macOS `⌘?` Help search is swallowed.

- `isNewChatHotkey` / `newChatHotkeyLabel` delegate to the map, keeping
  their signatures and their truth table, so "what ⌘J means" has one
  answer. The physical `KeyJ` arm only widens the set.
- `resolveComposerKey` claims BARE arrows only. It used to claim an
  arrow whatever the modifiers were and the caller `preventDefault`s a
  `move-*`, so ⌥↓ / ⌥⇧↓ were silently dead whenever an @-popup happened
  to be open — a handler claiming a chord it never declared.
- The reserved ⌘K resolves to nothing: the shell must not
  `preventDefault` a key it cannot answer, so the browser's Ctrl+K keeps
  working until Spotlight ships. It is hidden from the key list too — a
  dead row in a help dialog is worse than an undocumented key.
- `keymapSuppressed` is a matrix: anything modal stops every shell key
  (⌘J included — today it remounts under an open file preview), while a
  call stops only the keys that would move you. ⌘J keeps its own "leave
  the call?" ask; ⌘/ is allowed, because opening a dialog leaves nothing.
- `hasModalOpen` is a DOM probe, not a registry: it sees overlays that do
  not exist yet, and its exemption is per action (the rail keys do not
  see the rail's own sheet; ⌘/ can close its own list).
- The stale `agentLanding` header comment is corrected — the
  switch-agent keys ARE a caller, passing arm 2's `lastOpenSessionId`;
  the gesture doors (New chat, the picker, ⌘J) still mean "fresh".

`portalKeymap.js` reuses `isMacLike` / `nextActiveIndex` and
`portalUtils.js` delegates back, which closes a module cycle. It is
benign — neither module touches an import of the other at evaluation
time — and both init orders are exercised: `workspaceKeymap.spec.js`
imports the map first, `portalChatTabsAndTitles.spec.js` the utils.

Tests: 190 pass across the four specs. Mutation battery, restored from a
scratch copy: dropping the bare-arrow guard reddens the composer row;
a chat chord without its Shift reddens four keymap cases including the
collision check; Shift on the physical arm reddens the Decision 29 case;
a reserved key that resolves reddens the ⌘K case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ityai/trinity-enterprise#621)

Checkpoint B of the Workspace key map. The map and its pure rules landed in
58350ed; this wires them to the shell.

* `Portal.vue`'s `onGlobalKeydown` becomes the ONE dispatcher over
  `resolveWorkspaceKey` + `keymapSuppressed`, with the full ladder: an
  unclaimed chord returns without `preventDefault` (⌘K keeps reaching the
  browser until ent#577), ⌘J keeps its place at the top and its own
  "leave the call?" ask, a nearer owner's `defaultPrevented` wins, a focused
  `<select>` keeps its native Alt+↓, and anything modal / the mobile drawer /
  a live call suppresses the rest.
* `lastOpen`: a plain session-scoped Map with ONE writer. `pendingSession` is a
  load instruction, not the chat on stage — a null under an unchanged agent is
  ignored, a null under a new agent is a real unsent new chat and is recorded
  as such, so `agentLanding`'s own arms return to the draft. (T1 / Decision 35)
* `landOnAgent(name, { lastOpenSessionId, focus })`, with the idempotency guard
  HOISTED above the focus-mode write and above `agentLanding` (Decision 34):
  the key path's own push re-fires the route watcher, and a second landing over
  a thread list that has meanwhile grown a minted Main would `openThread` it,
  replacing the chat the key just landed on.
* `stepAgent(±1)` walks `orderedRoster`, lifted to the shell (T6) and handed to
  both sidebar instances, which stop sorting — the keys must walk the order the
  eye reads, and there were two of those computeds before. The fresh arm pushes
  `/workspace/a/:name` AFTER the state write, so the watcher re-fire is absorbed.
* `PortalConversation.cycleChat(delta)` + `defineExpose` (an identifier, not an
  inline body): the strip's inputs live there, and it emits `open-thread` /
  `new-chat` exactly as a tab click does.
* `PortalSidebar` gains `activeAgentName` → `aria-current`, an active-row tint
  on the semantic `action-primary` token, auto-expand when a walk lands beyond
  the fold, `visibleAgentRows(..., { keep })` and the same keep in search
  (Decisions 27/37). The sidebar had no notion of a current AGENT at all, by
  key or by click.
* An `aria-live` announcer for every move; `data-ws-rail-sheet` on the same
  element that carries the sheet's `aria-modal`, so the probe can exclude it.

Red list re-pinned in the same commit, never loosened (Decision 42):
`portalVoiceMode.spec.js` exact `defineExpose` string, `workspaceNewChat.spec.js`
`landOnAgent` signature + guard-first shape, `portalAgentsAtCentre.spec.js`
roster-order pin moved to the shell plus a new `keep` case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…walk (Abilityai/trinity-enterprise#621)

Checkpoint B's behavioural half. Both files MOUNT (#2918): a regex over
`Portal.vue` passes an inverted suppression condition and a reversed `delta`
byte-identically.

* `workspaceKeymap.mount.spec.js` — the real `Portal.vue` (shallow) with ONE
  custom stub: the conversation, because the auto-stub exposes nothing and
  `conversationRef.value?.cycleChat?.()` is optional-chained twice, so deleted
  wiring would hide behind its own `?.`. Real `KeyboardEvent`s on the real
  `window`. 18 cases: the walk and both wraps, the caret returning to the field,
  a roster of one, the fresh arm (route + `focusOnMount`), the announcer, the
  last-open memory (return, unsent-new-chat, a stale id falling through), the
  chat keys reaching `cycleChat(±1)`, and the whole suppression ladder — modal,
  the rail sheet's per-action exemption, the drawer, a live call,
  `defaultPrevented`, a focused `<select>`, reserved ⌘K, and `e.repeat`.
  Wrappers are unmounted between cases: one left standing answers the next
  test's key press first and `preventDefault`s it, after which the shell
  correctly yields and the case reads as broken wiring.
* `portalChatCycle.mount.spec.js` — 8 cases on the real conversation: the walk
  agrees with `agentChatTabs`' own order, both directions wrap, other agents'
  chats are not in it, the provisional tab is walked from and onto (`new-chat`,
  as a click answers), under two tabs is silent, and `cycleChat` is exposed.
* `portalAgentsAtCentre.spec.js` gains the `keep` behaviour (function) and the
  sidebar's call site (structure) — pinned inside the `visibleAgentRows(` call,
  because the file's other `keep:` is `searchAgents`' and a loose pin stayed
  green with the collapse's argument deleted.

Mutation battery, each reverted from a scratch copy and restored byte-identical:
`defaultPrevented: false` → "yields to a nearer owner" red; `-delta` in
`cycleChat` → 5 cases in portalChatCycle red; `keep` dropped from the collapse →
"orders the roster BEFORE the collapse" red; `focus: 'always'` dropped → "a key
press IS a gesture" red; `lastOpenSessionId: null` → "returns to the chat that
was open there" red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#621)

Checkpoint C. The three remaining shell keys land on the ONE dispatcher B
built — no second listener — and every control the keys drive now says which
key drives it, derived from the same map the dispatcher resolves against.

- `⌘.` toggles the rail in whichever form the viewport shows (column at and
  above `sm`, bottom sheet below it), reusing `railState.tab` and the
  persisted width: the key stores nothing of its own, so it cannot disagree
  with the expand/collapse button's memory (T5).
- `⌥.` opens a closed rail on its remembered tab first, then walks `railTabs`
  — the tabs this session may SEE — never the registry, so a door-failed tab
  stays as unreachable by key as it is by click (Decision 25).
- Both rail keys are suppressed, NOT dispatched to a no-op, where there is no
  rail: the difference is `preventDefault`, and a `⌘.` that silently ate
  Safari's Stop on every rail-less page would be a worse bug than a missing
  feature. `keymapSuppressed` reads the map's own `needsRail` flag for this.
- `⌘/` opens `PortalKeyList` on `BaseModal` (#1923's Esc, focus trap and
  focus-return; always mounted and `v-model`-driven, because a `v-if` toggle
  unmounts past the close branch and focus never comes back). Every row is
  rendered from `keyListRows(WORKSPACE_KEYMAP, …)`; ⌘K is reserved for ent#577
  and has no row, because a help dialog promising a key nothing binds is worse
  than an undocumented key. The list is the one action allowed to see through
  its own dialog, so the chord that opened it closes it.
- Discoverability: agent rows, the rail's expand/collapse and tab buttons, and
  the chat tabs each carry the chord in BOTH channels — the tooltip people
  read and `aria-keyshortcuts` in ARIA's spelling — plus a sidebar-footer
  button so a mouse reaches what `⌘/` reaches. All of it comes through
  `keyHint` / `keyShortcutsFor` over the map: a typed glyph would be a second
  declaration of the same chord, and the copy that goes stale is always the
  one nobody tests.

`BaseModal` now passes its attributes to the DIALOG element rather than
dropping them on `Teleport`: `data-ws-key-list` has to sit on the same element
as `aria-modal`, because that is the element the dispatcher's `:not(...)`
exclusion matches. Without it the marker is a comment, not a selector.

The sidebar footer's two icon buttons share one class string (`PortalRail`'s
`ICON_BTN` shape) — two identical inline copies is two places for a hover
state to drift, and the raw-colour ratchet counts every copy.

Tests (all mounts, no source-text pins): the rail and key-list cases in
`workspaceKeymap.mount.spec.js` prove a real `keydown` on the real `window`
reaches the real dialog in the document — `teleport`, `BaseModal` and
`PortalKeyList` are deliberately unstubbed, since the `[aria-modal]` probe can
only see an overlay that actually reached `<body>`. `portalKeyList.mount.spec.js`
asserts rows against the map (never against repeated strings) and drives Esc,
the close button and focus-return with focus placed by hand first.
`portalKeyHints.mount.spec.js` asserts each control's two attributes equal
`keyHint`/`keyShortcutsFor` output, so a hand-typed hint fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ai/trinity-enterprise#621)

T6 lifted `orderedRoster` out of `PortalSidebar` and into `Portal.vue` — one
order, computed once, handed to both sidebar instances so the switch-agent keys
walk exactly the order the eye reads. Three specs pinned the identifiers at
their old address and went red on the lift rather than on a broken rule:

- `portalRosterRow.spec.js` asserted `visibleAgentRows(orderedRoster.value` and
  the `orderedRoster` computed in the sidebar's source. Both are gone from that
  file, and neither proved more than "the code was written". Converted to a
  MOUNT of `PortalSidebar`: the bound is now a count of rendered rows, and "the
  sidebar does not re-sort what it was handed" is a deliberately unsorted roster
  coming back out in the same order. A re-sort that slipped back in fails this
  even though it would satisfy any spelling of the old regex.
- `portalSidebarRecency.spec.js` pinned the `null` primary (ent#500 does not
  exist) on the sidebar's call site. Re-pinned onto the shell's — the one call
  site there now is. It stays a call-site pin: "the primary argument is null" is
  an argument-position fact no rendered order can show, because with ent#500
  absent a named primary and null agree on screen.
- `portalSidebarSearch.spec.js` pinned the same `orderedRoster.value` spelling;
  re-pinned onto `props.roster`, plus the negative that keeps the lift honest —
  no second `orderRosterAgents(` inside the sidebar.

No assertion was dropped or weakened; each was shown red by a one-line mutation
(a re-sort in the sidebar, a reintroduced second sort, a guessed primary in the
shell), with both source files restored byte-identical after each.

Sweep green: 130 files / 2368 tests, including the raw-colour, loading-gate and
source-text ratchets (the source-text baseline is unchanged — the mount
conversion left `portalRosterRow` at its one existing read).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rinity-enterprise#621)

Checkpoint D. The docs described a Workspace with one inline hotkey; it now has
a declared map, a single dispatcher, and one landing rule the keys call.

- `requirements/core-agent.md` §5.21a — the new sub-section beside §5.21: the
  nine working chords, the reserved one, one declaration / one dispatcher, the
  inverted editable-target rule (every key works from the message field), the
  suppression rule, the non-US `key`-or-`code` statement, the roster-order and
  last-open rules, and discoverability. OSS-core recorded explicitly rather
  than inferred from the merge.
- `feature-flows/workspace-chat-tabs-and-titles.md` — "New chat in the header"
  keeps the ent#451 facts and gains "The key map": the table, the matcher's
  platform-free rule, the dispatcher's ladder in order (the order IS the
  design), the two rules it leans on, and the key list that is built FROM the
  map so a key cannot ship undocumented. Title and front matter follow; the
  Tests section names the ent#621 specs.
- `feature-flows/workspace-agents-at-the-centre.md` — the stale half of "two
  doors RESOLVE, the rest ASSERT": the switch-agent keys resolve now, and they
  pass `lastOpenSessionId`. Walking the roster is navigation, not a request for
  a blank page. The pure-helper table says `orderRosterAgents` is called once,
  in the shell.
- `architecture/frontend.md` — the Workspace shell paragraph: one map, one
  listener, the ladder, and the two consequences for anyone adding a key.
- `Portal.vue` — one stale dispatcher comment ("the rail keys land in the next
  checkpoint" — they landed in C). The `agentLanding` header comment the plan
  also named was already corrected in Checkpoint A.

Public wording only: the shipped behaviour, no enterprise design detail and no
link to any private design document. The enterprise-docs guard's own pattern
runs clean over `docs/` and `CLAUDE.md`.

Sweep still green: 130 files / 2368 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…21 review H1)

The last-open writer tested only "did the agent change", so a null
`pendingSession` under an unchanged agent was always read as a load
instruction. But ⌘J and the strip's New chat tab (both `newChatWithAgent`)
null it on the agent already on stage — the commonest way to be sitting on
an unsent chat — so ⌥↓ then ⌥↑ came back to the chat ⌘J had just left
instead of the new one, against AC-8.

The watcher now also observes `startingNewChat` and passes
`keyChanged || (fresh && !sid)`: a deliberate fresh chat records `null` for
the current agent, while every load-instruction clear (`openAgentPage`,
`openRoom`, the unreachable arm, `openThread`) leaves the flag false and is
still ignored, so the chat the person was reading survives those.

Two mount cases: ⌘J on the agent on stage then ⌥↓/⌥↑ returns the new chat,
and a load-instruction clear under an unchanged agent still returns the old
one. Reverting only the watcher turns the first red and leaves the second
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding (ent#621 review I1-I3)

I1 — the ladder sentence read as though nothing claims the event before the
suppression rung, but ⌘J during an active voice call calls preventDefault
first and routes to the leave-call guard. That is the pre-existing
ent#534/551 ask, kept deliberately, so the exception is now stated in
architecture/frontend.md and in AC-3 rather than the code being moved.

I2 — the docs named a `platform` argument `keymapCollisions(map)` does not
take. Matching is platform-free, so one call is the whole proof; the three
mentions say that instead of implying a per-OS check.

I3 — "one window listener" becomes "one chord dispatcher" in frontend.md
(and AC-3), naming PortalRail.vue's pre-existing sheet-only Esc listener as
the `close-top` protocol entry it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w listener (ent#621 review I3)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…browser (ent#621 review)

A key repeat resolved to nothing and the dispatcher returned before
`preventDefault`, so the auto-repeat of a chord the shell had just claimed
went to the browser. Reproduced against the rebased branch: a held ⌥. types
`≥` into the message field on a Mac, and a held Ctrl+J opens Downloads on
Chrome/Firefox for Windows and Linux (⌘J's repeats were swallowed before
the map existed).

The dispatcher now remembers the action of the press it claimed (`heldKey`)
and swallows repeats of that chord, matched through the new pure
`workspaceChord`. A repeat still never dispatches, the repeat of a press the
shell did not claim stays the browser's, and any new press ends the hold.

Also re-pins the "no rail" case after the ent#784 review fix: a landed
agent URL is a chat with a rail, so ⌘. works there; the rail-less page in
the test is now the unreachable-agent refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cut tips (ent#621 follow-up)

- `⌘/` (Ctrl+/) puts the cursor in the sidebar's "Search agents and chats"
  field and selects its text; pressed again from the field it hands the
  caret back to the message field. Below `sm` it opens the drawer and
  focuses that instance. The field shows the chord (key cap, title,
  aria-keyshortcuts). Chosen to echo the Dashboard's `/` filter key.
- The shortcuts list moves to `⌥/` to free the chord, keeping the
  `⌘.` / `⌥.` pairing.
- `PortalKeyTips`: a small panel pinned to the bottom of the open rail with
  four headline chords and an "All shortcuts" button; one icon at the foot
  of the collapsed strip; neither on the mobile sheet. Rows are derived
  from the key map (`KEY_TIPS` -> `keyTipRows`). Dismissible, remembered
  per browser in localStorage.
- `PortalKeyCap`: the one `<kbd>` recipe for the key list, the tips and the
  search field.

Requirements: core-agent.md 5.21a AC-1/9 amended, AC-11 and AC-12 added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ollow-up)

The panel was a heading, four rows and a footer button (213px). It is now
two rows of two with the close and open-the-list controls stacked beside
them (80px), and nothing above or below.

- `keyHintCompact` packs a chord pair that differs only in its last key
  into one cap (`⌥↑↓`, `Alt+Shift+↑↓`); anything it cannot pack without
  losing something falls back to `keyHint`.
- Columns size to their content; when the rail is dragged narrow a label
  shortens with an ellipsis (full text on hover) while a key cap never
  wraps and a row never becomes two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts in the landing rule's doc, portalUtils.js, Portal.vue and
workspaceNewChat.spec.js, all between ent#784 (already carried here) and
ent#621's options bag on landOnAgent; resolved to the ent#621 side, which
is a superset of dev's hunk in every case. Portal.vue keeps the key map
as the only "⌘J" definition, so dev's isNewChatHotkey import stays dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…son confirms (ent#621 merge-train)

The call branch `preventDefault`ed the event and then re-entered the ladder
with that same event, so on the resumed pass the suppression rung read the
shell's own mark as "a nearer owner claimed it" and bailed — ⌘J was dead
exactly when the person had said "end the call and leave". The continuation
now re-enters as the `resumed` pass after a `nextTick` (the confirm dialog is
aria-modal and leaves the DOM on that flush), and `keymapSuppressed` waives
only the `defaultPrevented` rung for it; modal, drawer and call still apply.

Also from the finding: the physical-`code` arm answers only when the printed
key is not a single printable ASCII character, so a Latin non-QWERTY layout
keeps paste and undo (Dvorak's ⌘V arrives on `Period`, ⌘Z on `Slash`). The
non-ASCII cases the arm exists for (`≥`, Cyrillic) are unchanged.

Tests: pure cases for the resumed rung and the Dvorak shape in
workspaceKeymap.spec.js; the shell mounted for real in
workspaceKeymap.mount.spec.js — ⌘J during a call asks, confirm fires the
new chat, cancel fires nothing; the two voice-mode source pins re-pinned to
the new continuation.

`isNewChatHotkey` stays: workspaceKeymap.spec.js and
portalChatTabsAndTitles.spec.js (here and on the stacked ent#621 follow-up
branch) import it as the ⌘J equivalence pin, and the chat-tabs flow documents
it as the delegate kept on purpose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-and-hints (ent#621 follow-up)

Picks up #3241's dev merge plus the ⌘J-after-leave-call fix and the
code-arm ASCII gate. One conflict, the keymapSuppressed doc comment:
kept this branch's ⌥/ wording and #3241's `resumed` clause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vybe
vybe changed the base branch from feature/ent621-workspace-keymap to dev October 6, 2026 18:01
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

Trinity Agent (trinity) and others added 2 commits October 7, 2026 12:01
…ollow-up)

Parent #3241 was squash-merged to dev (42543a4). Every conflicted
file resolves to dev's version (identical to #3241's final head
54cd121, review fixes included) plus only this PR's own delta:
⌘/ focuses the sidebar search, the shortcuts list on ⌥/, the
PortalKeyTips rail panel, keyHintCompact and PortalKeyCap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trinity-ability

Copy link
Copy Markdown
Contributor Author

Brought up to date with dev (operator request, 2026-10-07) — merge, not rebase; normal push 212aaec3b..9def95bfd.

Merge commits

How the conflicts were resolved. dev's copies of all 14 files this PR touches were byte-identical to #3241's final head 54cd121, so its review fixes are all included. Each file was rebuilt as dev's version plus this PR's own delta (git diff 54cd121 212aaec3b): ⌘/ focuses sidebar search, the shortcuts list on ⌥/, the PortalKeyTips rail panel, keyHintCompact and PortalKeyCap. I checked the result two ways:

  • git diff origin/dev HEAD is exactly that delta: 14 files, +740/−33
  • outside those 14 files, the tree matches dev

Tests (src/frontend, vitest, at 9def95bfd)

  • Touched specs (workspaceKeymap.spec.js, workspaceKeymap.mount.spec.js, portalKeyHints.mount.spec.js, portalKeyTips.mount.spec.js, plus portalBackgroundAskInboxOnly.spec.js): 5 files, 110/110 passed
  • Full unit suite (npm run test:unit): 284 files, 4957/4957 passed

Note: at the first merge, the full suite had 1 failure: portalBackgroundAskInboxOnly.spec.js. Its fixture had aged past the 7-day window. It failed the same way on a clean dev checkout, so this branch didn't cause it. #3348 fixed it on dev, and the second merge picked that up.

The PR is still a draft and has not been merged.

🤖 Generated with Claude Code

…nsumed tip-row aria field

- workspace.md / FAQ said ⌘/ opens the shortcuts list; it is ⌥/ now. Add the
  ⌘/ search row and the rail's shortcut-tips panel.
- keyTipRows' ariaKeyshortcuts had no consumer (aria-keyshortcuts belongs on
  the control the chord activates, not the tip row) — removed with its assertion.
- Stale ⌘/ → ⌥/ in test names and comments about the key list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe
vybe marked this pull request as ready for review October 8, 2026 09:13
@vybe

vybe commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

merge-train: pushed to this branch — merged dev (clean), then b268da5ce: the user docs (workspace.md, Workspace FAQ) still said ⌘/ opens the shortcuts list — now ⌥/, with a ⌘/ search row and a line on the rail's tips panel; dropped keyTipRows' ariaKeyshortcuts (no consumer — the attribute belongs on the control a chord activates) and its assertion; stale ⌘/ → ⌥/ in key-list test names/comments. Body de-staged (#3219/#3241 merged), marked ready. Touched specs + ratchets: 142/142.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: validated in the 2026-10-08 batch

@vybe
vybe merged commit c8d5a92 into dev Oct 8, 2026
26 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants