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
Draft
feat(workspace): the Workspace key map — agents, chats and the rail from the message field (ent#621)#3241vybe wants to merge 10 commits into
vybe wants to merge 10 commits into
Conversation
vybe
force-pushed
the
feature/ent784-new-chat-default
branch
from
October 5, 2026 16:06
24fb2be to
dace0c2
Compare
vybe
force-pushed
the
feature/ent621-workspace-keymap
branch
from
October 5, 2026 16:06
bbed543 to
1161e9a
Compare
…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>
vybe
force-pushed
the
feature/ent621-workspace-keymap
branch
from
October 5, 2026 16:26
1161e9a to
7136e8c
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
components/portal/portalKeymap.js, new):WORKSPACE_KEYMAPdeclares every Workspace chord: the shell keys, ⌘J (from ent#523;isNewChatHotkeynow delegates to the map), ⌘K reserved (no handler and nopreventDefault, 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 bykeyorcodefor non-US layouts, with repeat and IME composition ignored;keymapSuppressed/hasModalOpen;keymapCollisions, which a spec asserts is empty;recordLastOpen,nextAgent,nextRailTab, andkeyListRows/keyHint/keyShortcutsFor.views/Portal.vue::onGlobalKeydown):<select>bail → suppressed (modal, mobile drawer, voice call, already handled, no rail) →preventDefault→ run. It is armed inonMountedabovebootstrap()'sawaitand removed on unmount.preventDefaultbefore the ladder.landOnAgent(name, { lastOpenSessionId })→agentLanding. There is no second landing rule.Mapin the shell with a single watcher writer, keyed on[activeAgentName, pendingSession, startingNewChat]. An unsent new chat is remembered asnulland returns through the drafts arm with its words. Nothing is stored.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:activeAgentNameprop witharia-current;PortalConversation.vue::cycleChat): walks the sameagentChatTabs(...)list the tab strip binds, and emits exactly what a tab click emits.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.PortalKeyList.vue, new): built onBaseModalfromkeyListRows(), with nothing hand-typed.PortalChatTabs.vue,OverflowTabs.vue), the rail toggle and rail tabs get tooltips andaria-keyshortcutsfrom 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.portalUtils.js::resolveComposerKey): the @-typeahead now claims only bare arrows, so ⌥↓ switches agent even with the popup open.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)
Mapwith one watcher writer. An unsent new chat isnulland lands throughagentLanding's drafts arm. No sentinel, no storage.PortalKeyList.vueonBaseModal, opened from a sidebar-footer button and ⌘/.[aria-modal="true"], with an ignore list per action.activeAgentName+aria-current+ auto-expand + search keep.components/portal/portalKeymap.js. T11 (a): the composer claims bare arrows only.portalKeymap↔portalUtilsis a deliberate module cycle with no top-level cross-reads, and specs cover both init orders;Review + security
/review(claude-fable-5-1, report-only, range24fb2be1..67d5dd99): MERGEABLE AFTER FIXES. These checks hold:await, IME suppressed;BaseModalconsumer blast radius (none moved);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 onpendingSession/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.db701482andbbed5438, I1–I3 (docs):keymapCollisionsis described as platform-free;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..claudeorsrc/backend/enterprisein the range. A merge-tree against the stacked base24fb2be1is clean, and the merged tree equals this tip's tree.Tests
bbed5438: every check is terminal, none failed.frontend-e2eran twice (onopened, then onlabeled): 94 passed in each run.agent-detail-request-dedupe.spec.js:73was flaky in both and passed on retry; it is the same unrelated flake feat(workspace): opening an agent starts a new chat with the cursor in the message field (abilityai/trinity-enterprise#784) #3219 saw.frontend-build, schema-parity, gitleaks, container-security and the guards are green.dev/main, so they did not run on this stacked base. They run when the PR re-targetsdev.67d5dd99: 134 vitest/jsdom files, 2397 tests, all green.portal*sweep: 102 files, 1914 tests green;workspace*/overflow*/baseModal*/*Ratchet*sweep: 28 files, 456 tests green.workspaceKeymap.spec.js(pure),workspaceKeymap.dom.spec.js,workspaceKeymap.mount.spec.js(shallowMount(Portal)+ realwindowkeydown),portalChatCycle.mount.spec.js,portalKeyList.mount.spec.js,portalKeyHints.mount.spec.js. Each checkpoint has mutation proofs.portalRosterRow(now mounted),portalSidebarRecency,portalSidebarSearch,workspaceNewChat,portalVoiceMode,portalAgentsAtCentre,portalChatTabsAndTitles,portalComposerTypeahead.frontend-e2ein CI is the gate.Before merge
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.⌥↓/⌥↑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.⌥↑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.⌘J→ type a draft →⌥↓→⌥↑returns the unsent new chat with its draft (review H1).⌥↓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.⌘.opens/closes the bottom sheet,⌥.cycles its tabs, andEsccloses it.⌥↓does NOT change agent behind the open sheet. Nothing is persisted as open on reload.⌘/opens the key list;Escand 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.⌥↓/⌥⇧↓/⌥./⌘.do nothing.Escstill closes the overlay or ends the call, and⌘Jduring a call still asks.↓moves the popup row and⌥↓switches agent.⌥↓still switches agent. Inside the title editor it does not, by design./= 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.Alt+Shift+↓ten times fast and three times slow (hold, pause, release). Record on this PR whether the input language switched (UC-1).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).⌘K/Ctrl+Kstill reaches the address bar (reserved, untouched), and⌘J/Ctrl+Jbehaves exactly as before this PR.router.pushruns before the state write. jsdom cannot observe this once the guard is hoisted, so no unit test pins it.BaseModalattrs 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.bbed5438. This branch adds no conflict to any of them; their existing conflicts withdevare their own.Handoffs
keyListRows()can feed its empty state.pendingSessionafter an agent switch. The review found it outside this issue's scope. It still needs to be filed as its own bug.🤖 Generated with Claude Code