Files
multica/packages/views/editor
Jiayuan Zhang 7ccf446b04 feat(issues): add header thread navigator to issue detail (#6426)
* feat(issues): add header thread navigator to issue detail

The right-edge rail is a good position indicator and a bad finder: a tick
carries no text, so locating a thread costs one hover per candidate, and once
threads outgrow the rail the ticks compress to their 5px floor and stop being
countable. Add a header entry point that lists the threads instead — 8-10
titles readable at once, plus search, resolution/@me filters, and day
grouping, so 24 threads and 240 threads cost the same to navigate.

Hover previews the list without taking focus; a press pins it, focuses search,
and enables arrow-key navigation. Pure hover would have been wrong for a
surface you must scroll, type into, and arrow through — all three need the
pointer free to leave the button.

The two navigators stay one coordinate system rather than two competing lists:
both derive the thread set and "you are here" from one memo and one shared
useVisibleThreadIds, and hovering a row lights that thread's tick on the rail.

Adds Mod+Shift+O (openThreadNav), mirroring "go to symbol in file".

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): tint search matches in the thread navigator

Filtering told the reader which threads matched but not why: a row that
matched on its excerpt looked identical to one that matched on its title,
so they had to re-find the term they had just typed. Tint the matches in
both fields using the same --find-match token as the in-page find bar.

Caught by driving the real app rather than the unit tests — the mock had
this and the implementation had quietly dropped it.

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): drop the viewport marker and use real keycaps in the thread panel

Two things the panel got wrong, both visible only once it had real data.

The "you are here" marker came from the same viewport set the rail uses to
darken its ticks, but "on screen" is a set, not a point: a tall viewport holds
several threads at once, so the marker landed on two or three rows and read as
a broken multi-select. Absolute position is the rail's job, where a column of
ticks can show a span honestly; the panel's job is finding. Removing it also
removes the panel's scroll-container dependency, so useVisibleThreadIds goes
back to being the rail's private hook.

The footer hints were hand-rolled kbd elements with arrows typed into the
translation strings, so they matched nothing else in the product. Use the
shared ShortcutKeycaps, which draws the same keycaps as the sidebar, command
palette, and confirm dialogs, with the platform's own glyphs. The trigger also
gains a tooltip like its neighbours, carrying the Mod+Shift+O keycaps — until
now the shortcut had nowhere to be discovered.

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): accept the command bar's Ctrl nav aliases in the thread panel

Ctrl+N/J move down and Ctrl+P/K move up, matching the command bar — cmdk
enables `vimBindings` by default and switches on exactly those four chords, so
the palette has always accepted them and the thread navigator was the odd one
out.

The policy already existed as pickerNavigationDirection, added for the editor's
mention and slash pickers for this same reason (MUL-5495). It lived in
suggestion-popup.tsx, which imports Tiptap and floating-ui, so reaching it from
a header popover would have dragged the editor's dependency graph along. Moved
both it and isPickerAcceptKey — pure KeyboardEvent predicates either way — to
common/picker-keys.ts, with their tests, and repointed the three consumers.

preventDefault matters for the letter aliases here specifically: focus sits in
the panel's search field, where Ctrl+K/N/P are readline editing commands that
would otherwise mangle the query while moving the cursor. Tab is deliberately
not an accept key in this panel, unlike the editor pickers, because Tab has to
stay focus navigation for the filter pills.

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): four keyboard and data correctness bugs in the thread panel

From review on #6426. All four reproduce; three were mine to begin with.

IME: handleKeyDown acted on every Enter, so a CJK user committing an IME
candidate in the search field jumped to the active row and closed the panel
mid-word. Guard with isImeComposing, the same check Cmd+F already uses.

Child controls: the handler sits on the popup so navigation works anywhere
inside it, which also caught Enter bubbling from the filter pills and the rows
— and preventDefault on a button's keydown cancels the click the browser was
about to synthesize. Enter on "Resolved" jumped away instead of filtering.
Handle Enter only when the search field is the target; buttons keep native
activation. The existing keyboard tests fired at the popup root, which no real
interaction targets, so they could not have caught this; they now fire at the
search field.

Legacy mentions: mentionsUser matched only mention://member/<id>, and its
comment claimed the old [@ id=... label=...] shortcodes were normalised before
storage. They are not — preprocessMarkdown migrates them on READ, and the
timeline hands us raw content, so "@me" silently dropped threads whose only
mention of the reader used the old format. Run the same normaliser first; it
returns its input untouched when there is no shortcode.

Timestamps: formatClock used "less than 48 hours ago" while threadDayGroup
used calendar days. The two disagree for most of the day, so an entry from
23:41 the day before yesterday grouped under "Earlier" but rendered as a bare
"11:41 PM" with nothing saying which day. Take the group as an argument —
one boundary cannot drift from itself.

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): one keyboard cursor in the thread panel, and a live shortcut hint

From the second review pass on #6426.

Focus and highlight could drift. Gating Enter to the search field last round
left the arrows firing for the whole popup, so Tab to a row, ArrowDown, Enter
moved the highlight to one thread and jumped to another — DOM focus and the
highlight were two pieces of state with nothing holding them together. Rather
than gate the arrows too and leave the rows as unreachable-but-focusable tab
stops, make the relationship structural: rows are role=option with tabIndex=-1,
the list is a listbox, the search field is a combobox that names the current row
through aria-activedescendant. Focus never leaves the search field for the list,
so there is only one cursor to disagree with. Tab still reaches the filter
pills, which keep native Enter activation.

This also fixes rows having outline-none with no focus-visible replacement:
they are no longer keyboard-focusable at all.

The shortcut hint used getShortcut(), a getState() snapshot with no
subscription, under a comment claiming it would pick up a rebind. It would not:
rebinding openThreadNav in Settings left the tooltip showing the old key while
the keydown handler already used the new one. useShortcut subscribes, and is
what chat-fab already uses.

MUL-5755

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-05 17:15:36 +08:00
..