Skip to content

Attaching a file, folder, or link works like a picker, not a syntax - #74668

Merged
OutThisLife merged 9 commits into
mainfrom
bb/composer-attach
Jul 30, 2026
Merged

Attaching a file, folder, or link works like a picker, not a syntax#74668
OutThisLife merged 9 commits into
mainfrom
bb/composer-attach

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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. detectTrigger returned 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:

Action Before After
@folder:apps/deskt + Tab @apps/desktop/ — scope dropped, next completion silently widened back to files @folder:apps/desktop/ — still browsing folders
Enter after that chip re-guessed folder from a trailing slash chip takes the kind it was browsing
Backspace at @folder: nibbled out through :, r, e, d… one character per keypress drops the whole scope in one key, mirroring Tab's one-key descent
Pick mid-message chopped the trailing prose off and stranded folder: in front of the chip chip lands at the caret, prose either side intact
Paste a link into @url: sent @url:@url:`https://…` — the directive doubled the open scope is consumed; one chip
Pick a folder popover said desktop/, editor said apps/desktop/, chip said desktop one label, derived once, on all three
/ menu rows no icons, a separate stacked layout same row as @, icon from the shared table
Walk a tree every keystroke an uncached round trip behind a 60ms debounce repeat paths served from cache, no debounce, no spinner

Tab now carries the scope down, Backspace drops it as one unit, and the rebuild fallback splits around the caret instead of slicing tokenLength off 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 a FOLDERS / FILES / LINKS header, 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 on data-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, with isCached wired 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:8080 stay 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 / TriggerPopoverBack model a scope as navigable state with a Back affordance, never as text in the buffer, and DirectiveNode.__label keeps one label on the node that every consumer renders verbatim.

530 tests across the composer and assistant-ui suites, all passing.

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Jul 30, 2026
@OutThisLife OutThisLife changed the title Fix composer attachments: treat @kind: as a browse scope, not editable syntax Attaching a file, folder, or link works like a picker, not a syntax Jul 30, 2026
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
OutThisLife enabled auto-merge July 30, 2026 09:59
@OutThisLife
OutThisLife merged commit 9650f55 into main Jul 30, 2026
31 checks passed
@OutThisLife
OutThisLife deleted the bb/composer-attach branch July 30, 2026 10:01
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on c999dc2

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

1 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants