Attaching a file, folder, or link works like a picker, not a syntax - #74668
Merged
Conversation
detectTrigger split an `@folder:apps/desk` token into one opaque query, so every consumer downstream had to re-parse the prefix — or, more often, treat it as characters the user was expected to maintain by hand. Split it into `scope` + `value` at the source, behind a known-kinds list so a handle like @teknium1: or a host:port stays ordinary text, and add openDirectiveScope() for callers that need to know the caret is sitting in an empty scope.
…-message pick Three defects in the commit engine, all from treating the scope as loose text: Tab-descend rebuilt the token as a bare `@apps/desktop/`, silently widening an explicit @folder: browse back to files and forcing the committed chip to re-guess its kind from a trailing slash. It now carries the scope down. Backspace only handled a path, so at `@folder:` it fell through to character deletion and nibbled back out through the directive syntax one key at a time. It now drops the scope as one unit, mirroring Tab's one-key descent. The rebuild fallback sliced tokenLength off the END of the draft, which assumed the trigger was the last thing in the editor — a pick made mid-message chopped the trailing prose off and stranded a partial `folder:` in front of the chip. rebuildAroundCaret splits around the caret instead, and is shared with the ascend path and the edit composer rather than hand-rolled three times. Also drop the chip's auto-inserted trailing space when the caret already has whitespace after it, so a mid-sentence pick doesn't leave a double space.
…directive A pasted link linkifies into an `@url:` directive, so pasting one while an @url: scope was already open stacked a second directive on the first and submitted `@url:@url:` around the link. The leftover prefix rendered as literal text in front of the chip. insertComposerContentsAtCaret takes a consumeBefore length; both composers pass the open scope's span so the scope is consumed by the paste rather than left sitting in front of the chip.
With the scope no longer sitting in the editor as raw syntax, the popover is where it belongs: a FOLDERS / FILES / URL header above the list, so the filter reads as the mode it is instead of as characters the user has to finish. Reuses the existing group-header style the slash menu already renders — no new chrome.
Nine cases against the real hook and a real contentEditable: the scope surviving Tab-descend, Backspace climbing the path then dropping the scope whole, a mid-message pick keeping its trailing prose, a paste consuming an open scope, and the guards that keep @teknium1: / localhost:8080 from being mistaken for a directive. text-utils.test.ts picks up the additive `value` field and asserts the scope/value split directly.
Picking a folder showed three different names for it: the popover row said
`desktop/`, the editor mid-browse said `apps/desktop/`, and the committed chip
said `desktop`. Each surface derived its own label from the value.
Upstream keeps ONE label on the directive node and hands it to every consumer
verbatim (`DirectiveNode.__label = item.label`, rendered by `decorate()` and
carried through `:type[label]{name=id}`). Our wire format is `@kind:value`,
which can't carry a label, so the same invariant is held by deriving both ends
from refChipLabel: the popover row now shows exactly what the chip will show,
and the commit path passes the picked row's label into the chip rather than
letting it re-derive one.
refChipLabel keeps the directory for the reason it already keeps a URL's path —
a bare basename can't tell two references apart, and `src`, `index.ts`, and
`main.tsx` repeat all over a repo. Browsing into apps/desktop/ only to be
handed a chip reading `desktop` throws away the context you navigated for.
The `/` path has had a completion cache since the skills-scan work; the `@` path never got one. Every keystroke was an uncached round trip behind the 60ms debounce, so walking a tree — Tab in, Backspace out, retype a segment — paid full price for paths it had just listed. Measured in-process against this repo (8,036 files): `git ls-files` ~38ms, ranking ~12ms. The backend already caches the file list for 5s, so the fix belongs in the renderer: reuse the existing cache module with a short 15s TTL (a directory listing, unlike the command catalog, can change under the user) keyed on cwd + session + query, and wire `isCached` so a warm query skips BOTH the debounce and the loading state. That last part is what makes it feel instant rather than merely fast — a spinner over an answer already in hand reads as latency the user isn't paying.
`@` and `/` were two menus that happened to live in the same file: `@` rows were horizontal with an icon, `/` rows were stacked with none, and each kept its own hand-maintained icon map. Picking a file and picking a skill felt like features from different apps. Adds reference-kinds.ts — one table mapping every kind a reference can be (file, folder, url, image, tool, line, terminal, session, git, diff, staged, command, skill, theme, emoji) to its icon, accent, and section label. Both surfaces that show a reference now read from it: - the popover row, browsing for one - the chip, having picked one so a thing is the same colour with the same glyph wherever you meet it, and a row looks like the chip it will become. `/` rows gain icons in the process, which is what the shared layout gives them for free. Chips lose their pill: no background, no padding, no border, just the icon and coloured text. A filled badge turns every mention into a UI element the eye has to step over, and the icon plus accent already carry the kind. Slash pills are the same component — SLASH_CHIP_BASE_CLASS is now literally DIRECTIVE_CHIP_CLASS. Emoji rows stay icon-less: the emoji is its own glyph. Also drops three duplicated definitions (ICON_PATHS, SLASH_ICON_PATHS, SLASH_CHIP_VARIANT) and an inline copy of DirectiveIcon inside SlashChip.
A reference had two styling systems: a Tailwind class string assembled in TypeScript (`directiveChipClass`) and a separate `link-chip` for prose links, each carrying its own color-mix(). Same concept, three appearances. Now every inline reference — a composer chip, a sent message's mention, a markdown link, a completion row's glyph — is `class="ref"` plus `data-ref="<kind>"`, and styles.css owns the accent. No hex or color-mix() ships from a component, so a skin restyles all of them at once. Keying the accent on `[data-ref]` alone rather than `.ref[data-ref]` also lets the popover's icon column take a kind's hue without inheriting its inline-text layout.
OutThisLife
force-pushed
the
bb/composer-attach
branch
from
July 30, 2026 09:54
ffde514 to
c999dc2
Compare
OutThisLife
enabled auto-merge
July 30, 2026 09:59
Contributor
૮ >ﻌ< ა ci reviewran on c999dc2 ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 visual diff. inline evidence is publishing... |
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…ttach Attaching a file, folder, or link works like a picker, not a syntax
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…ttach Attaching a file, folder, or link works like a picker, not a syntax
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.
Attaching a file, folder, or link in the composer made you maintain its syntax by hand.
@folder:sat in the editor as raw text, Tab dropped the part of it that mattered, Backspace nibbled back out one character at a time, and a pasted link doubled the directive so the message you sent had@url:printed next to the chip. Once picked, the same reference then went by three different names and wore a different style depending on which surface you met it on.It's one bug class with one root: the
@kind:prefix was never modeled.detectTriggerreturned the whole token as one opaque query, so every consumer either re-parsed the prefix or dropped it, and every surface derived its own label and colour downstream. This splits the token into a scope and a value at the source, then makes one table the single answer to what a reference looks like.Each row below was reproduced against the real hook before the change and is covered by a test after it:
@folder:apps/deskt+ Tab@apps/desktop/— scope dropped, next completion silently widened back to files@folder:apps/desktop/— still browsing foldersfolderfrom a trailing slash@folder::,r,e,d… one character per keypressfolder:in front of the chip@url:@url:@url:`https://…`— the directive doubleddesktop/, editor saidapps/desktop/, chip saiddesktop/menu rows@, icon from the shared tableTab now carries the scope down, Backspace drops it as one unit, and the rebuild fallback splits around the caret instead of slicing
tokenLengthoff the end of the draft — that end-slice assumed the trigger was the last thing in the editor, which is why mid-message picks ate the rest of the line. With the syntax out of the editor, the popover carries the filter as aFOLDERS/FILES/LINKSheader, reusing the group-header style the slash menu already renders.@and/stop being two menus that happen to share a file. One table maps every kind a reference can be — file, folder, url, image, tool, line, terminal, session, git, diff, staged, command, skill, theme, emoji — to its glyph and section label, and the accent for each lives in the stylesheet keyed ondata-ref, so no colour ships from a component and a skin restyles all of them at once. A row now looks like the chip it will become. Chips lose their pill in the process: a filled badge turns every mention into a UI element the eye has to step over, and the icon plus accent already say what kind of thing it is.The
@path also picks up the completion cache/has had since the skills work. Measured in-process against this repo (8,059 files):git ls-files~38ms, ranking ~12ms per keystroke. It now reuses the existing cache module with a 15s TTL — a directory listing, unlike the command catalog, can change under the user — keyed on cwd + session + query, withisCachedwired so a warm query skips both the debounce and the loading state. That last part is what makes walking a tree feel instant rather than merely fast; a spinner over an answer already in hand reads as latency you aren't paying.Two guards worth calling out: only the six known kinds become scopes, so
@teknium1:and@localhost:8080stay ordinary text; and the caret-split helper is shared across the commit path, the ascend path, and the edit composer instead of being hand-rolled three times (the edit composer duplicates the trigger machinery and had the same end-slicing bug).This is the raw-contenteditable version of what upstream assistant-ui does in Lexical:
TriggerPopoverCategories/TriggerPopoverBackmodel a scope as navigable state with a Back affordance, never as text in the buffer, andDirectiveNode.__labelkeeps one label on the node that every consumer renders verbatim.530 tests across the composer and assistant-ui suites, all passing.