Skip to content

feat: mention two level hierarchy - #35

Merged
lodystage[bot] merged 14 commits into
mainfrom
feat/mention-two-level-hierarchy
Aug 11, 2026
Merged

lodystage[bot] merged 14 commits into
mainfrom
feat/mention-two-level-hierarchy

Conversation

@Leeeon233

Copy link
Copy Markdown
Contributor

No description provided.

Leeeon233 and others added 14 commits August 8, 2026 08:47
…medium]

Groundwork for the single-`@` two-level mention menu. No user-visible
change: the four existing sources insert exactly the same text as before.

- `MentionItem` gains `insertText` (commit) and `navigateText` (drill-down).
  Both replace the whole span from the trigger to the caret and carry their
  own leading marker; without them the primitive still composes
  `${trigger}${label}`.
- `navigateText` generalises the directory drill-down into a navigation
  contract: rewrite the span, keep the menu open, record no range and no
  selected value. The primitive no longer infers navigation from a trailing
  `/` — the `@` file menu now passes both props explicitly.
- `MentionKind` widens so a committed range can name its own type.
- Keyboard: Tab/ArrowRight descend into a highlighted navigation item;
  Backspace/ArrowLeft pop a `<namespace>:` prefix back to the bare trigger.
  Path drill-downs are excluded so Backspace still walks a path one
  character at a time.

The one intentional behaviour delta: Tab on a directory candidate now
descends instead of closing the menu.

The `MentionSource` registry skeleton listed for this step is deferred to
the migration that gives it callers, rather than landing a type with none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
The logic layer the `@` two-level menu will render. No UI change: nothing
consumes the registry yet, and the four existing menus are untouched.

- `selectMentionMenuView` maps the text between the trigger and the caret to
  one of three levels: the category list (`@`), an aggregate search across
  every category (`@foo`), or one scoped category (`@issue:foo`). Keeping
  `@foo` on aggregate search is deliberate — a strict two-level menu would
  retire today's one-step `@filename`.
- Candidate builders reuse each source's existing ranking and keep each
  type's prompt form (`@path`, `#123`, `$token`, `/cmd`), so reaching a type
  through `@` does not change what the agent receives.
- `getCandidates` is lazy per category: a bare `@` ranks nothing and a scoped
  query never ranks the file index.
- Issues and PRs rank over their own slice of the shared cache. The shared
  ranking caps its result set, so ranking the merged list would let a long
  issue list starve every PR out of the PR category.

`getIssuePrSuggestions` and its helpers are exported for reuse; the issue/PR
module is otherwise unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
Renders the registry as one menu: a category list at `@`, an aggregate
search at `@foo`, and one scoped category at `@issue:foo`. Gated on the new
`twoLevelMentions` prop — no caller passes it yet, so the per-type menus
remain the default and are untouched.

- Category rows carry `navigateText`, so selecting one rewrites the input to
  `@<ns>:` and stays open instead of committing. Candidate rows carry their
  own `insertText`, so an issue reached through `@` still writes `#123`.
- The second level shows a breadcrumb with a visible Back button. Mobile has
  no Backspace habit, so the way out cannot be keyboard-only; the click path
  reuses the primitive's pending-selection channel rather than touching the
  DOM selection, which would race the controlled value commit.
- `/` keeps a direct route through the command category's `directTrigger`: a
  slash command owns the whole prompt and never nests under a category.
- Fuse loading stays inside the open menu and reuses one module-cached
  import, so mounting a composer still never pulls Fuse in.

Storybook coverage and the first-level-to-second-level analytics funnel are
still outstanding for this step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
`@` becomes the only menu trigger. The two-level menu is no longer behind a
flag, and the four per-trigger menus are deleted.

- `/` stays the one exception and still opens commands directly: a slash
  command must own the whole prompt, so it never nests under a category.
- `#` and `$` no longer open a menu, but their hydrators stay. A hand-typed
  or pasted `#123` / `$token` is still highlighted, and `$token` still
  expands to its skill path before send — only the menu route is gone.
- Deleting the old menus removed every mention analytics event with them, so
  the funnel is re-established on the new menu with a `category` dimension:
  `menu_open` -> `category_enter` -> `select`. `category_enter` is reported
  from the resolved view rather than a row callback, because a navigation
  item never fires `onMentionSelect` and the keyboard route must count.
- `mention-skill-menu-idle-measure.test.tsx` is deleted with its subject. It
  guarded against measuring the composer while the `$` menu was closed; the
  new menu performs no measurement at all, so the bug class is structurally
  gone rather than merely untested.

Known regression: the `$` menu's two-pane detail panel (description, scope,
version, path) had no equivalent in the new menu and is not carried over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
Recovers the one real capability the `$` menu lost when it was replaced:
its two-pane detail view (description, scope, version, path, symlink target).

Modelled as neutral `MentionCandidateDetail` fields on the candidate rather
than a per-source component, so a single pane serves every category — issues
and sessions get one for free. Desktop only, matching the old menu: the
docked mobile strip is too narrow and has no hover to preview with.

The pane follows the highlight and falls back to the first candidate so it is
populated before the highlight effect lands, as the `$` menu did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
The locale files are flat dotted-key maps because i18next runs with
`keySeparator: false`. The keys added for the two-level menu were written as
nested blocks, so every one of them resolved to nothing and fell back to its
inline default — invisible in English and untranslated everywhere else.

Rewrites them as flat keys, preserving the file's existing key order, and
adds the missing `detailSymlink` key the detail panel needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
Replaces the coverage lost with `SkillMentionMenu.stories.tsx`: every level
and state of the single `@` menu — categories, aggregate search, each scoped
category, the detail panel, its mobile list-only form, loading, error,
truncated file list, and no-results.

Candidates are built through the real registry mappers, so a story breaks if
the insert-text or detail mapping drifts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
Adds `@session:` and, with it, the layer the composer was missing.

## The type

The composer holds `@session:<title-slug>`, the mention range holds the real
`sessionId`, and the token is rewritten on send into
`use lody mcp to query session[id: <id>] history`.

The split is forced, not stylistic: the composer is a textarea with a
character-aligned highlight overlay, so it cannot render a chip whose width
differs from its text. Showing a title while sending an id has to go through
the token-plus-expansion route `$skill` already uses.

Consequences that needed deciding:

- `@session:` stays in the committed text, unlike every other namespace
  prefix, because it is the anchor expansion matches on. That also makes
  expansion idempotent for free — a re-sent prompt has no anchor left.
- Slugs resolve through the live session list first, then a `localStorage`
  slug -> id map that survives a reload or a rename. It is synchronous on
  purpose: expansion runs on the send path, and an async store would make
  that entire path async for a few hundred bytes.
- An unresolvable slug is sent verbatim. A stale token the agent can ignore
  beats a confidently wrong session id.
- Duplicate titles disambiguate the *older* session, so the most recent
  holder of a title keeps the clean slug and re-typing it stays stable.
- The detail panel shows the exact text that will be sent, so the one place
  where display and payload diverge is visible rather than surprising.

## The layer

`useMentionPromptExpansion` becomes the single before-send transform and
composes the per-type expansions. There are exactly two send paths (chat
landing and the session composer), and each previously wired every expandable
type by hand — adding one meant editing both. It reads its own data rather
than taking it through props, so a send path no longer has to know which data
an expansion needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
`currentSessionId` reached the send-path expansion but never the menu, so a
session listed itself as a mentionable candidate — referring to history the
agent already has.

Threads it from the session composer through `ChatComposer` to
`CombinedMentionTextarea`. Chat landing passes nothing, which is correct: a
new chat has no session to exclude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
…sk:medium]

Arrow keys could not leave the first group of an aggregate search.

`useFilterStore` was running without `manualFiltering`, so on top of each
category's own ranking the primitive scored the search term against every
item's `value` and hid the misses. Under `@mention` a file row survived
(its path contains the term) but an issue row did not — its payload is
`#3312`. A hidden row renders null, so its collection entry lost its DOM
node, the position sort collapsed, and movement stopped at the first group.

Categories already rank and slice their candidates, so the primitive must
not filter at all.

Also trims the menu chrome to what earns its space: the second level keeps
only the way back, category rows are just their name, and a session row is
just its title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
Trimming the menu chrome left `MentionCategory.hint` with no renderer — the
first-level row and the breadcrumb were its only two readers. Removes the
field, its six i18n keys in both locales, and the fixtures that carried it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
…[risk:medium]

Arrow keys still broke across groups after the filtering fix: entering the
next category showed no highlight, and moving on jumped back to the first.

`MentionItem` was registering `{ current: itemNode }` — a fresh snapshot
object built from state that is null on the first render. The collection keys
its map by that object and sorts entries by document position through
`.current`, so every stale snapshot left a null-node entry. `compareNodePosition`
returns 0 for those, collapsing the sort, and `findIndex` matched the
highlighted item against the wrong row: no highlight, then index 0 on the next
move.

Registering the component's stable ref instead keeps one entry per item with a
`.current` that always tracks the live node.

Also drops the `@session:` side panel: a session row is its title and project,
and the id and the outgoing instruction were noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Model: claude-opus-5
…tion-two-level-hierarchy

# Conflicts:
#	locales/en.json
#	locales/zh_CN.json
…ke work [risk:medium]

Cleanup pass over the two-level `@` menu. No intended behavior change apart
from the two noted below.

Reuse and dedupe:
- `mention-hydration.ts` owns the hydrate-once effect and range merge that
  file/session/skill/issue-PR hydrators each had a copy of.
- `context.onNavigateBack()` owns the drill-down pop-back once; the menu's
  Back button and Backspace/ArrowLeft both call it instead of restaging the
  value-commit/caret-restore transaction separately.
- `mention-trigger.ts` owns the `@<ns>:` grammar; the registry parses through
  it rather than keeping a second regex.
- `useSessionMentionItems` is the single owner of the mentionable-session
  list, which the composer and the send-path expansion both derived.
- `getEffectiveLatestMessageAt` replaces a weaker local timestamp parse, and
  `getMentionViewCandidates` replaces three copies of the view flatten.

Wasted work:
- Issue/PR slices are partitioned once and shared with their Fuse indexes.
- `getCandidates(term, limit)` lets a source stop before mapping results the
  aggregate level drops; `selectMentionMenuView` still enforces the cap.
- The session category is capped like the others.
- The Fuse constructor is latched, so closing the menu no longer discards
  every index and rebuilds it on the next `@`.
- The slug cache skips a `setItem` when the serialized map is unchanged, and
  session hydration reads it only for drafts carrying the anchor.

Dead code: six unused analytics helpers, `issue-pr-trigger-refresh.ts`, an
empty import, `SkillMentionMenuPlacement`, `replacesWholePrompt` (set and
asserted, never read), and three orphaned locale keys.

Behavior fixes: the skill detail scope badge goes through i18n instead of
rendering the raw enum, and a local session's subtitle no longer reads
"local". Registers `lody:session-mention-slugs` in the cache-clear list, and
keeps `MentionKind` product-neutral in the shared primitive.

Model: claude-opus-5[1m]
@lodystage
lodystage Bot merged commit 848f1a0 into main Aug 11, 2026
@lodystage
lodystage Bot deleted the feat/mention-two-level-hierarchy branch August 11, 2026 07:42
Leeeon233 added a commit that referenced this pull request Aug 26, 2026
* fix: restore reliable skill mentions

Model: GPT-5

* fix: stabilize mention detail scrollbar

Model: GPT-5

* fix: stabilize mention detail height

Model: GPT-5

* fix: advertise skill mention trigger

Model: GPT-5

---------

Co-authored-by: Leeeon233 <leeeon233@gmail.com>
Leeeon233 added a commit that referenced this pull request Sep 9, 2026
* fix: keep forked Codex sessions subscribed

Pin acp-extension-codex to fc91dce (LodyAI/acp-extension-codex#37) so
thread/fork children stay subscribed for turn/completed. Pin
acp-extension-core 0.1.2 because that adapter already depends on it.

This does not take Lody#534 host worktree identity changes. Adapter #35
is already on Codex main and comes along with #37.

Closes #543

* docs: record Codex fork subscription pin

Agent Note for pinning acp-extension-codex fc91dce and Core 0.1.2
without taking Lody #534 host identity changes.

Model: grok-4.6

* fix: pin DSH main with awaited smoke tests

Use merged acp-extension-dsh #14 to fix the no-floating-promises CI error without weakening lint checks.

Model: gpt-6

---------

Co-authored-by: Leon Zhao <leeeon233@gmail.com>
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