Skip to content

Composer: @ file mentions and a / command palette (#22) - #94

Merged
derekhearst merged 13 commits into
mainfrom
feat/22-composer-mentions
Sep 24, 2026
Merged

derekhearst merged 13 commits into
mainfrom
feat/22-composer-mentions

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

Summary

I implemented #22 at triage option B scope. Typing @ at the start of a word, or pressing @ Context, opens a fuzzy-ranked list of files and folders. It comes from a new guarded remote query, searchWorkspaceFiles in src/lib/chat-console/mentions.remote.ts. The listing code is in mentions.server.ts and workspace-files.server.ts. The walk never follows or lists symlinks and re-checks each directory's real path before reading it. It skips .git, node_modules and build output, caps entries, depth and time, caches each root for 15s with one shared walk, and never shells out to git or rg. Picking a file inserts its workspace-relative path as inline code. Typing / as the first character, or pressing / Commands, opens a local palette: /compact, /model, /agent, /research, /plan, /effort, /attach and /voice. Each command calls the same handler as the button it stands for. /model, /agent and /effort open a second list with the current value marked. A message typed out as a known command runs it, and any other text that starts with / is sent as typed. Commands are plain data with a source field, so the SDK's own commands can be added later. Both menus share one generic popover, ComposerSuggestMenu.svelte, that never takes focus. Arrows move, Enter/Tab accept, Escape closes with stopPropagation, Enter with an empty list still sends, and Enter is ignored while an IME is composing (isComposing or keyCode 229). The two disabled placeholder pills are replaced, and the chat page's dead mobile quick row moved into the composer, where it now works. Tapping a row on a phone picks it. The only edits to the chat page are two imports, two props, and the removed quick row. bun run check has 0 errors and 0 warnings, bun run build passes, and 339 of 341 tests passed across the new and neighbouring specs on both projects; the other 2 are the known Windows failure (details under specs_run).

After review: Addressed every item in the review of #22. The branch is up to date with origin/main (merged, 0 commits behind, no conflicts). The worktree is clean and nothing was pushed. Must-fix: the /model list works out the rows it shows and the row it starts on together, from one array (choiceMenu in composer-commands.ts, which finishes the WIP that was uncommitted). When the current model sorts past the 50-row cut, it is moved to the top. The composer no longer fixes a start index when the menu opens. Its chosenIndex stays null until the user presses an arrow key or moves the pointer, so a model list that loads after the menu opened still starts on the current model. Should-fixes: (1) Enter and Tab do nothing while an @ search is loading with no rows, so a half-typed mention is no longer sent. (2) Merged origin/main; the Deep Research row in the parity table keeps both notes. (3) /voice refuses while the last recording is still being transcribed. /compact and /plan refuse while a reply is running; the page passes one streaming getter. (4) /research takes only its own line, and the draft below stays; the parser's unused whole-message argument field was removed. (5) The suggestion menu's title span now ends in an ellipsis. docs/chat/spec.md was updated to describe all of this. The three new page specs and the new ellipsis check were each run against the old code: they fail there and pass with the fix.

Closes #22

What was addressed

Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.

# Item Verdict Note
must_fix:ChatComposer.svelte:400 /model highlights wrong row must_fix:ChatComposer.svelte:400 /model highlights wrong row fixed choiceMenu(query, choices) returns the rows shown and the start row from the same array; the current value leads when it is past CHOICE_LIMIT (50). The menu derived carries initial; active = chosenIndex ?? menu.initial, and chosenIndex resets to null on every trigger or query change, so the highlight follows the current value until an arrow key or pointer move. initialActiveIndex is removed. Unit specs cover 300 choices with the current one past the cut, inside the cut, with a query, and loaded after open. A page spec seeds a conversation with the model at row 100 of the live OpenR
claimed_not_fixed:current value marked claimed_not_fixed:current value marked fixed Same fix as the must-fix: /model, /agent and /effort now always show the current value, marked, and it is the highlighted row, including when the list loads after the menu opened. The WIP was finished, wired in and committed (df87099).
should_fix:ChatComposer.svelte:159 Enter sends while @ search loading should_fix:ChatComposer.svelte:159 Enter sends while @ search loading fixed When view.kind === 'mention' && view.loading && items.length === 0, Enter and Tab (without Shift) call preventDefault and return. A settled empty list and the no-workspace message still let Enter send. The page spec holds the searchWorkspaceFiles /_app/remote request with a route, presses Enter and Tab, and checks that nothing was sent, the value is unchanged and focus stayed. After the request is released, Enter inserts README.md. The spec fails on the old composer.
should_fix:claude-desktop-parity.md:78 merge conflict should_fix:claude-desktop-parity.md:78 merge conflict fixed Merged origin/main (d119835). The Deep Research row keeps main's #90 model-id/cancel note and records that /research <question> in the composer now starts the background pipeline. check, build and the specs were run again on the merged tree. merge-tree against the latest origin/main is clean.
should_fix:composer-commands.ts:221 commands skip button disabled state should_fix:composer-commands.ts:221 commands skip button disabled state fixed voiceCommand takes a transcribing getter and gives an unavailable reason. compactCommand(fn, { replyRunning }) and planModeCommand({ ..., replyRunning }) return REPLY_RUNNING while a reply runs. buildChatPageCommands takes streaming, and the chat page passes streaming: () => streaming (a one-line edit to the hot file). Unit specs cover these refusals.
should_fix:ChatComposer.svelte:553 /research absorbs draft should_fix:ChatComposer.svelte:553 /research absorbs draft fixed runTypedCommand uses parsed.lineArgument for the text argument, with rest = stripCommand(value, { consumeLine: true }). The hint now reads 'Type the question on this line…', and the unused ParsedCommand.argument was removed. A page spec picks /research through the / Commands button with a draft, intercepts startResearchCommand (decoding the base64url payload), and checks that the question excludes the draft and the box keeps 'keep this draft'. It fails on the old composer. Spec rule 3 was updated.
should_fix:console.css:1793 menu title no ellipsis should_fix:console.css:1793 menu title no ellipsis fixed .console-suggest__head > span:first-child gets min-width:0, overflow hidden, text-overflow ellipsis and nowrap, and .loading gets flex:0 0 auto. The /effort page spec checks the ellipsis and that the title ends inside the menu. It fails on mobile without the CSS change.

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Both projects (desktop and mobile), E2E_PORT=4503, with the port patch reverted afterwards. On the final commit: tests/chat.composer-suggest.unit.spec.ts, tests/chat.composer-mentions.spec.ts, tests/chat.workspace-files.spec.ts, tests/auth.remote-gate.spec.ts, tests/auth.remote-guards.spec.ts, tests/research.composer.spec.ts gave 154 passed, 0 failed. An earlier run that also included tests/engine.permission-mode.spec.ts gave 256 passed. The new page specs were also run against the old ChatComposer.svelte and console.css, and they fail there as expected.
  • Independent review: changes-needed (1 must-fix, 5 should-fix), addressed in a follow-up round.

Docs updated

  • docs/chat/spec.md
  • docs/structure/claude-desktop-parity.md

Follow-ups / residual risk

  1. Owner decision still open, from the reviewer's notes: /research starts the background research pipeline directly. The owner had earlier removed the composer's Research button in favour of the Research agent, so they should confirm that this is what 'start research' should mean; the alternative is for /research to switch to the Research agent. 2) The /model page spec uses the live OpenRouter catalogue (fetched without a key, as the app does) and skips itself when it cannot reach it. That keeps it stable in CI but means it can skip without testing anything. The pure choiceMenu unit specs cover the same logic without the network. 3) Not done, as the reviewer said it was not blocking: listWorkspaceFiles could reuse isPathWithin from containment.server.ts instead of its local isWithin. There is also a very small race between the safePathWithin check and the realpath/readdir, and only the conversation's owner could ever see the result. 4) /research is still allowed while a reply is paused on an ask_user question, because the reviewer did not flag it. It goes to the new research page, and the paused turn stays in the review inbox. 5) An older stash on main from the user's checkout (stash@{0}, 'WIP on main: 656c86e') is still in the shared stash list; it was not touched.

🤖 Generated with Claude Code

derekhearst and others added 13 commits September 23, 2026 16:36
…he composer

The pure half of #22, with no DOM and no remote calls, so one spec pins it.

- composer-trigger: where an `@` mention or a `/` command starts and ends
  around the caret. `@` only opens at the start of a word, so an email
  address does not; `/` only as the first character, and `/usr/bin` is not
  a command. Accepting a mention writes the path as inline code, not
  `@path`, so what the model receives does not depend on how the SDK treats
  an `@`.
- mention-match: an fzf-style subsequence score (word starts and runs
  score, gaps cost) with the best alignment rather than the first, file-name
  matches ahead of folder matches, and a greedy fallback for pathological
  lengths.
- composer-commands: a command is plain data plus the handler its button
  already calls (compact, model, agent, research, plan, effort, attach,
  voice), with a `source` so the SDK's own commands can join the list later.
  /plan only ever toggles between Plan only and Ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
searchWorkspaceFiles lists files in the folder the conversation's next turn
will start in, so a mentioned path is one that turn can open as written.
That is the bound project's checkout or an agent's persistent workspace. A
chat whose every turn gets a fresh runs/<id> directory or worktree has
nothing to list, and says so, rather than offering paths from the last run
that the next one will not find.

workspaceKind names the five workspace shapes in the priority order
resolveWorkspaceRoot already used, which now switches on it, and
resolveConversationWorkspace reports the kind of the latest run and of the
next turn.

The walk runs over a tree the agent writes to: symlinks are never followed
or listed, each directory's real path is re-checked before it is read,
.git, node_modules and build output are skipped, entries, depth and time are
capped, and nothing but relative names leaves it. It never runs git (an
agent-written .git/config can make git execute a program on the host) or rg.
A finished listing is cached per root for 15 seconds, with one walk shared by
concurrent callers. Someone else's conversation is answered like a missing
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er (#22)

The two placeholder pills are live. Typing `@` at the start of a word, or
pressing @ Context, opens the chat's files; typing `/` as the first
character, or pressing / Commands, opens the palette. Both share one
suggestion menu that never takes focus: the textarea keeps it and drives the
menu with arrows, Enter or Tab to accept, and Escape to close (stopped from
reaching the drawers' window listener). An open menu with nothing in it lets
Enter send as typed, and Enter during IME composition neither sends nor
picks, which also fixes Enter sending half-composed text.

A command that needs a choice (model, agent, effort) opens a second list on
the current value; /research takes the rest of the message; a message that
starts with a known command runs it instead of being sent, and / Commands
keeps an existing draft on the line below. Edits land on the element
directly rather than after tick(): with async deriveds on the page tick()
can wait on unrelated work, and a keystroke in between went to the old caret.

On a phone the quick row above the box (Attach, @ Context, / Commands) had
three buttons that did nothing; it moves into the composer, where they
work, and a tap on a menu row picks it without dropping the keyboard. The
chat page passes the file search and its own commands (compact, research,
plan) and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat spec gains a plain-English section on `@` mentions and the `/`
palette: which folder is listed and when there is none, what is skipped and
why, every command and where it is available, and the keys. The chat-console
doc notes the file list's containment rules beside the preview's. The
parity table gets a row for the feature (behind: the SDK's own commands are
not listed yet), the deep-research row notes /research, and the re-audit
records the owner's choice to ship both halves now and what the SDK half
still needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the Deep Research row in the parity table: keeps main's note on
the background pipeline's model-id fix (#90) and this branch's note that
`/research <question>` in the composer now starts one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a loading `@` search

The `/model` list highlighted the wrong row, so Enter or Tab switched to a
model the user never picked (and could start an auto-compaction turn on the
way). The starting row was an index into the full catalogue, but the menu
showed only its first 50 rows: a current model past row 50 (most of them,
sorted by name) was not on screen at all, and the highlight landed on row
49. And on the first `/model` of a page load the list had not arrived when
the starting row was worked out, so row 0 was highlighted once it did.

`choiceMenu` now works out the rows shown and the starting row together from
the same array, and puts the current value first when it falls past the cut.
The composer keeps no fixed starting index: the highlight follows the menu's
own starting row until the user moves it with the arrows or the pointer, so a
list that loads after the menu opened still starts on the current value.

While an `@` search is still loading (the debounce, the round trip, or a cold
walk of up to 1.5s), the menu had no rows, so Enter fell through and sent
"look at @READ" as typed. Enter and Tab now wait for the list; a settled empty
list and the no-workspace message still let Enter send.

Specs: `choiceMenu` with 300 choices and the current one past the cut, inside
it, with a query, and loaded after the menu opened; on the page, the first
`/model` for a conversation on a model at row 100 of the live catalogue (it
skips when OpenRouter is unreachable), and Enter/Tab held while the file search
is held back by a route. Both page specs fail on the previous composer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d /research keeps the draft

A palette command is meant to call exactly what its button calls, but some
skipped the button's disabled state:

- `/voice` could start a new recording while the last one was still being
  transcribed; the mic button is disabled then. It now says why instead.
- On a chat page the message box reopens while a reply is paused on an
  ask_user question. There `/plan` changed the permission mode although the
  mode chip is disabled (the running turn keeps the mode it started with, so
  "Plan mode is on" was misleading), and `/compact` cleared the command and
  then did nothing, because the Compact handler returns early mid-stream.
  Both now refuse with "A reply is still running". The page passes one
  `streaming` getter to `buildChatPageCommands`.

`/research` took every line after the command as the question, so a draft the
`/ Commands` button had moved below the slash became part of the research
query and the box was cleared. The question is now the command's own line and
the lines below stay in the box, as with every other command; the parser's
unused whole-message `argument` is gone.

Specs: the refusals as units; on the page, /research through the `/ Commands`
button with the research command intercepted, checking the question it was
sent and the draft left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a phone

The menu header truncated with `text-overflow: ellipsis`, but the title is a
flex child span, which the header's ellipsis does not reach. A long choice
title such as "/effort: Set how hard the model thinks before answering" ran
off a ~340px phone menu with no ellipsis and pushed the loading dots out of
view. The title span now truncates itself (min-width 0), and the dots keep
their size.

The /effort page spec checks the title's ellipsis and that it ends inside the
menu; it fails on mobile without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #14 (PR #92): the right rail is Preview + Files, folded to a
strip by default; the context ring and cost chip move into the chat
topbar; the console-state streaming/activity fields are removed.

No textual conflicts. Checked for semantic ones:
- console-state.svelte.ts: the composer never read the removed fields
  (streamingBlocks, liveContext, runStatus, totals); its `/` palette
  calls the page's compactContext and handleResearchSubmit, which the
  rail kept.
- chat/[id]/+page.svelte: the composer's onMentionSearch and commands
  props sit beside the rail's topbar ring and Files wiring; the removed
  placeholder quick-chip row stays removed (ChatComposer owns it now).
- preview.server.ts / workspace.server.ts: the composer's
  nextTurnKind/workspaceKind additions are untouched by the rail's new
  rail-open.server.ts and changed-files.ts.
- README, docs/chat-console, docs/chat/spec.md, parity docs: both
  sides' text kept (rail paragraph + route map, @-mention workspace
  rules, #14 and #22 rows each describe their own shipped state).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #9 (PR #93, including the claude-opus-5-5 allow-list commit):
SUBSCRIPTION_MODEL_IDS in engine/model-backend.ts, unrunnable models
refused on send and on save, the OpenRouter gateway, the engine model
list (getEngineModels) and backend labels in the picker.

Conflicts:
- src/lib/chat/ChatComposer.svelte: import block only. Kept the
  composer's #22 imports (mention-match, composer-trigger,
  composer-commands) and the gateway's isClaudeModel import; the
  gateway's reasoningAvailable, surface="engine" model pill and the
  disabled reasoning control merged cleanly into the #22 template.
- README.md: kept both paragraphs, the gateway's model paragraph first
  (it explains which models the pickers offer), now naming the
  composer's `/model` command among those pickers, then the composer
  shortcuts paragraph.

Merged cleanly and checked: chat/[id]/+page.svelte (engineModels and
engineContextLimit replace getAvailableModels beside the composer's
commands/onMentionSearch props; /compact still calls compactContext),
the parity docs (#9 and #22 rows each keep their own state).

The composer's `/model` and `/effort` still follow the old catalogue
and the always-on reasoning control; fixed in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e reasoning control (#22, #9)

The `/` palette's `/model` still fetched OpenRouter's whole catalogue
(getAvailableModels) after #9 limited the model pill to the engine
list, so the palette offered hundreds of models the next send would
refuse, and it never marked a conversation stored in OpenRouter's
spelling (`anthropic/claude-sonnet-4.5`) as current. `/effort` stayed
open on a gateway model while the reasoning pill it mirrors is
disabled there.

- `/model` reads getEngineModels, the list the pill uses; a gateway row
  says "Gateway · paid" beside its id, and the current model is matched
  through normalizeModelId.
- `/effort` takes the pill's reason ("Reasoning is off for gateway
  models") as its `unavailable`: listed but switched off in the
  palette, and typed out in full it says why instead of opening its
  levels; a choice list never runs a command that became unavailable.
- tests: the `/model` e2e now checks the engine list (subscription
  models only without a gateway) and the OpenRouter-spelled current
  model; a new e2e covers `/effort` on a gateway model; unit tests for
  the gateway row detail and `/effort`'s unavailable. The composer
  spec seeds chats on a runnable model instead of the retired
  `anthropic/claude-sonnet-4`.
- docs: chat spec's command table and list notes, llm.md's pickers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #92 and #93 as merged, including #93's follow-ups (stale stored model
falls back to the default; race-safe settings creation; claude-opus-5-5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst merged commit 3a28de3 into main Sep 24, 2026
1 check passed
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.

Composer: slash commands and @-file mentions

1 participant