Skip to content

feat(workspace): the Workspace key map — agents, chats and the rail from the message field (ent#621) - #3241

Draft
vybe wants to merge 10 commits into
feature/ent784-new-chat-defaultfrom
feature/ent621-workspace-keymap
Draft

vybe wants to merge 10 commits into
feature/ent784-new-chat-defaultfrom
feature/ent621-workspace-keymap

Conversation

@vybe

@vybe vybe commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#621. A single issue. Stacked on #3219, so the base is feature/ent784-new-chat-default. The agent keys use #3219's one landing rule (agentLanding), and the two PRs merge together, #3219 first, on the operator's call. Draft until then. This change is mostly frontend. It was built through the chain playbook on the operator's ruling on the issue, so the hands-on key checks under Before merge are a blocking part of the review.

What

With the caret still in the message field, you can switch agents (⌥↑/↓), switch chats (⌥⇧↑/↓), show or hide the rail (⌘.), change its tab (⌥.) and open the key list (⌘/). Every key is declared once, in one map.

  • One key map (components/portal/portalKeymap.js, new): WORKSPACE_KEYMAP declares every Workspace chord: the shell keys, ⌘J (from ent#523; isNewChatHotkey now delegates to the map), ⌘K reserved (no handler and no preventDefault, so the browser's Ctrl+K keeps working until search ships), and the existing Esc/close protocol entries. It also exports pure helpers:
    • resolveWorkspaceKey / matchesChord: platform-free (primary = meta XOR ctrl), matching by key or code for non-US layouts, with repeat and IME composition ignored;
    • keymapSuppressed / hasModalOpen;
    • keymapCollisions, which a spec asserts is empty;
    • recordLastOpen, nextAgent, nextRailTab, and keyListRows / keyHint / keyShortcutsFor.
  • One dispatcher (views/Portal.vue::onGlobalKeydown):
    • Ladder order: resolve the chord → signed in → known action → <select> bail → suppressed (modal, mobile drawer, voice call, already handled, no rail) → preventDefault → run. It is armed in onMounted above bootstrap()'s await and removed on unmount.
    • One exception: ⌘J during a call keeps its existing ask and calls preventDefault before the ladder.
    • The agent keys land through landOnAgent(name, { lastOpenSessionId }) → agentLanding. There is no second landing rule.
    • Last-open memory: one in-memory Map in the shell with a single watcher writer, keyed on [activeAgentName, pendingSession, startingNewChat]. An unsent new chat is remembered as null and returns through the drafts arm with its words. Nothing is stored.
  • Sidebar (PortalSidebar.vue): the shell now computes the roster order once (orderedRoster) and passes it down, so the order the keys walk is the order shown. Other sidebar changes:
    • an activeAgentName prop with aria-current;
    • auto-expand when a key moves onto an agent hidden below the fold;
    • search keeps the active row;
    • a "Keyboard shortcuts" footer button.
  • Chats (PortalConversation.vue::cycleChat): walks the same agentChatTabs(...) list the tab strip binds, and emits exactly what a tab click emits.
  • Rail (PortalRail.vue): the rail keys reuse the existing rail state (tab and open width); nothing new is persisted. They do nothing on pages with no rail.
  • Key list (PortalKeyList.vue, new): built on BaseModal from keyListRows(), with nothing hand-typed.
  • Hints: agent rows, chat tabs (PortalChatTabs.vue, OverflowTabs.vue), the rail toggle and rail tabs get tooltips and aria-keyshortcuts from the same map.
  • BaseModal.vue: inheritAttrs: false + v-bind="$attrs" on the dialog div. The root is a <Teleport>, so extraneous attrs used to be dropped with a warning; now they land on the dialog. The review checked all 13 existing consumers: each passes only declared props and emits, so nothing moved.
  • Composer (portalUtils.js::resolveComposerKey): the @-typeahead now claims only bare arrows, so ⌥↓ switches agent even with the popup open.
  • Docs: docs/memory/requirements/core-agent.md (the key map requirement), architecture/frontend.md, and the chat-tabs and agents-at-the-centre feature flows.

Rulings carried (orchestrator, on the operator's behalf; recorded in the plan file)

  • Operator, 2026-10-05 (on the issue): build it, stacked on feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) #3219; hands-on key checks go under Before merge.
  • T1 (a): last-open memory is an in-memory Map with one watcher writer. An unsent new chat is null and lands through agentLanding's drafts arm. No sentinel, no storage.
  • T2 (a): the key list is PortalKeyList.vue on BaseModal, opened from a sidebar-footer button and ⌘/.
  • T3 (a): one dispatcher with the ladder above. "Modal" means [aria-modal="true"], with an ignore list per action.
  • T4: chords as recommended (⌘/Ctrl+., ⌥., ⌘/Ctrl+/, ⌥↑/↓); repeats ignored.
  • UC-1: the operator's ⌥⇧↑/↓ is kept. The Windows input-language observation is in Before merge.
  • T5 (a): rail keys reuse the existing rail tab and width; nothing new is stored.
  • T6 (a): the roster order is lifted to the shell. T7 (a): activeAgentName + aria-current + auto-expand + search keep.
  • T8 (a): moving keys and rail keys are silent no-ops during a call; ⌘J keeps its ask; ⌘/ is allowed.
  • T9 (a): ⌘K is reserved and hidden from the list. T10 (a): components/portal/portalKeymap.js. T11 (a): the composer claims bare arrows only.
  • Builder deviations, accepted:
    • the map has 15 entries rather than 11, because the protocol entries are declared;
    • portalKeymap ↔ portalUtils is a deliberate module cycle with no top-level cross-reads, and specs cover both init orders;
    • composer focus is restored after agent/chat keys, not after a rail-tab change;
    • the active-row tint uses a semantic token, which keeps the raw-colour ratchet green.

Review + security

  • /review (claude-fable-5-1, report-only, range 24fb2be1..67d5dd99): MERGEABLE AFTER FIXES. These checks hold:
    • one map, one chord dispatcher, one landing rule;
    • ⌘K reserved, Esc unchanged;
    • ladder order, listener armed above the await, IME suppressed;
    • the BaseModal consumer blast radius (none moved);
    • the module cycle;
    • every old-rule fingerprint re-pinned;
    • public and range hygiene;
    • the raw-colour ratchet.
  • Fixed in c7a3cb38, H1: a new chat started (⌘J / new-chat tab) on the agent already on stage was not remembered, so ⌥↓ ⌥↑ returned to the old chat instead of the unsent draft. The watcher now also keys on pendingSession / startingNewChat. Two new mount cases cover it: the same-agent new chat is remembered, and a load-instruction clear is still ignored. Reverting the watcher turns only the first case red.
  • Fixed in db701482 and bbed5438, I1–I3 (docs):
    • I1: the ⌘J-during-a-call exception is documented, with no code move;
    • I2: keymapCollisions is described as platform-free;
    • I3: "one window listener" now reads "one chord dispatcher" everywhere. PortalRail's sheet-only Esc listener is the declared close-top entry.
  • /cso --diff: 0 findings at the 8/10 gate. The run was partial because the engineer's container has no docker, but none of the image-internal checks it skipped apply to a frontend-and-docs diff.
  • No Alembic revision, no backend change, no .claude or src/backend/enterprise in the range. A merge-tree against the stacked base 24fb2be1 is clean, and the merged tree equals this tip's tree.

Tests

  • CI on bbed5438: every check is terminal, none failed.
  • Review evidence run at 67d5dd99: 134 vitest/jsdom files, 2397 tests, all green.
  • After the fixes:
    • targeted set: 11 files, 232 tests green;
    • portal* sweep: 102 files, 1914 tests green;
    • workspace* / overflow* / baseModal* / *Ratchet* sweep: 28 files, 456 tests green.
  • New specs: workspaceKeymap.spec.js (pure), workspaceKeymap.dom.spec.js, workspaceKeymap.mount.spec.js (shallowMount(Portal) + real window keydown), portalChatCycle.mount.spec.js, portalKeyList.mount.spec.js, portalKeyHints.mount.spec.js. Each checkpoint has mutation proofs.
  • Re-pinned to the new rules, not weakened: portalRosterRow (now mounted), portalSidebarRecency, portalSidebarSearch, workspaceNewChat, portalVoiceMode, portalAgentsAtCentre, portalChatTabsAndTitles, portalComposerTypeahead.
  • Not run here: Playwright and any live stack. frontend-e2e in CI is the gate.

Before merge

  • Merge order: feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) #3219 first, then this PR. feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) #3219's branch is 7 ahead / 36 behind live dev, so bringing it up to date is part of the merge train. This PR adds no conflict on top of feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) #3219.
  • Hands-on key checks. Run them in a real Workspace on Mac and Windows browsers, in both themes. Each covers an AC bullet a unit test cannot prove.
    • With the caret in the message field, press every key in the map once: nothing is typed into the field and each does its action (Mac: Chrome, Safari, Firefox; Windows: Chrome, Edge, Firefox).
    • ⌥↓ / ⌥↑ walk the sidebar top-to-bottom in its shown order and wrap at both ends. The active row is highlighted, and the header band and browser-tab title follow each move. The caret is in the message field after each move, for both a thread and a fresh landing.
    • Open agent A's chat X, switch to B, and type an unsent draft in a NEW chat with B. ⌥↑ back to A → X as left; ⌥↓ to B → the drafted new chat with its words. A third agent never opened lands exactly as a click would. Then click an agent row (not a key) and ⌥↑ back: the memory survived the click.
    • Same agent: ⌘J → type a draft → ⌥↓ → ⌥↑ returns the unsent new chat with its draft (review H1).
    • With one agent on the roster, ⌥↓ does nothing and shows no error. On a roster of more than five, a key move onto a hidden agent expands the list and highlights the row. A hand-collapsed list keeps the active row visible.
    • ⌥⇧↓ / ⌥⇧↑ move through the chat tabs in the strip's order. From an unsent new chat, one ⌥⇧↓ opens the most recent earlier chat. A draft stays with the chat it was typed in. The caret is in the field after each move.
    • ⌘. hides the rail and reopens it on the tab it last showed, at the width it last had (drag a width first). On a window too narrow for three columns, the rail opens without pushing the conversation under its floor (parity with the expand button).
    • ⌥. opens a closed rail on its remembered tab, then cycles only the tabs this account can see (check as a platform user AND as an external client), wrapping after the last. On the agent page and Projects, both rail keys do nothing.
    • Below 640px (or a tablet with a keyboard): ⌘. opens/closes the bottom sheet, ⌥. cycles its tabs, and Esc closes it. ⌥↓ does NOT change agent behind the open sheet. Nothing is persisted as open on reload.
    • ⌘/ opens the key list; Esc and a second ⌘/ close it, and focus returns to the message field. Rail rows read "no rail on this page" where there is none. The list is correct in light and dark, and the sidebar footer button opens the same list.
    • Tooltips: an agent row, a chat tab, the rail toggle and a rail tab each show their key. VoiceOver (Mac) and NVDA (Windows) read the shortcut on those controls and announce the agent/chat/tab after a key move.
    • Modal suppression: with the file preview open, a delete confirm open, the agent picker open, the mobile drawer open, and during a voice call, ⌥↓/⌥⇧↓/⌥./⌘. do nothing. Esc still closes the overlay or ends the call, and ⌘J during a call still asks.
    • With the @-typeahead open in the composer, bare ↓ moves the popup row and ⌥↓ switches agent.
    • With focus on a chat row's star button or a rail tab button (Tab to it), ⌥↓ still switches agent. Inside the title editor it does not, by design.
    • Non-US layouts: German (/ = Shift+7), French AZERTY (. = Shift+;), Russian (Cyrillic): ⌘., ⌘/ and ⌥. work. On a US Mac, ⌘⇧/ still opens the Help-menu search. The key list's layout footnote reads correctly.
    • Windows with two input languages installed, Chrome + Firefox: press Alt+Shift+↓ ten times fast and three times slow (hold, pause, release). Record on this PR whether the input language switched (UC-1).
    • Linux GNOME, Chrome + Firefox: Ctrl+. toggles the rail and does not open the emoji chooser. macOS Safari during a page load: ⌘. does not stop the load (or note it if it does).
    • Reduced motion on: a rail tab change and the key-list open/close show no animation, in both themes.
    • Browser defaults preserved: ⌘K/Ctrl+K still reaches the address bar (reserved, untouched), and ⌘J/Ctrl+J behaves exactly as before this PR.
  • Defence in depth, not a tested invariant: on the fresh-landing arm, router.push runs before the state write. jsdom cannot observe this once the guard is hoisted, so no unit test pins it.
  • BaseModal attrs passthrough is a change to a shared component. The review found no current consumer affected; any new consumer that passes a class or attrs will now see them on the dialog div.
  • Sibling PRs that share files (feat(workspace): Discuss and Dismiss on asks (abilityai/trinity-enterprise#747, #748) #3181, feat(workspace): the autonomy dial — what a companion may do unprompted (ent#641) #2984, feat(workspace): a stage advance reaches the Work card on the write, not the next poll (ent#533) #2713, feat(workspace): the rail's Canvas/Files dot lights on the write, not the next refetch (ent#532) #2709) were test-merged against tip bbed5438. This branch adds no conflict to any of them; their existing conflicts with dev are their own.

Handoffs

🤖 Generated with Claude Code

@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 5, 2026
@vybe
vybe force-pushed the feature/ent784-new-chat-default branch from 24fb2be to dace0c2 Compare October 5, 2026 16:06
@vybe
vybe force-pushed the feature/ent621-workspace-keymap branch from bbed543 to 1161e9a Compare October 5, 2026 16:06
Trinity Agent (trinity) and others added 10 commits October 5, 2026 17:22
…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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants