Repository navigation
Conversation
A user could reach a subagent only when the model chose to call `Task`, so work the user already knows is separable had no entry point. The delegation machinery is complete and already has one implementation — cards, topology, the permission queue, TaskWait and the context budget all key off "this turn contains a Task call", and none of it asks who initiated it. This adds the missing door rather than a second path: the composer offers the same catalog `Task` is built from, and main rewrites the typed draft into an explicit Task instruction while the user's own text stays in the transcript as a chip, exactly as /skill already works. `@` is extended rather than `/`, because `@` already means "reference an entity" and both mentions serialize to an `@token` on the wire. That collision is resolved by rule, not by UI: a token is an agent only when it names a delegate and the workspace holds no file of that name, so a user who really has an `explorer` file gets the file. Since Task exists in Agent mode only, Plan and Goal omit the group and refuse a hand-typed mention instead of letting the model answer a request the user meant to delegate. The rewrite is a strong prompt, not an enforced dispatch, the same trade-off /skill accepts. Enforced dispatch would need a public runtime entry point for a private tool, a new RPC, and a synthetic parent row, and would hand the parent a report it never asked for. fixes vastsa#986
The composer autocomplete probe only ever built command and file rows, so the new delegate rows would have shipped with no layout coverage at all: a description that overflows its row, or a menu that stopped labelling its group, would have passed CI silently. The probe now renders a third mode — "@" with the Agents group above a file row — and asserts what a user actually depends on there: the exact @token, a description that truncates instead of stretching the row, the agents ordered ahead of the file, and the group label being present. File-mode assertions now address the file row by its actual index rather than assuming it is first, which is what the group heading changed. fixes vastsa#986
The Agents group label was the only heading the "@" menu rendered, so a menu holding both kinds showed the delegates and then the file rows underneath that same label. The rows were still correct — a file row was never a delegate, and accept() still inserted the path — but the menu read as one mixed section, which is the one thing a user cannot be left to guess at when the two things are opposite in meaning. Both sections are now headed, "Agents" and "Files", in the nine shipped catalogs. Slash mode is untouched: its five command kinds keep their own labels and order. The layout probe now counts the headings and pins their order, which turned up a latent flaw in the probe itself: it reused one React root across the three modes, so the previous mode's heading stayed in the document and was counted against the current menu. Each pass now gets a fresh root. fixes vastsa#986
A delegate row wore a branch glyph, which reads as "fork" or "diff" — the icon the composer already uses for anything shaped like a tree, and not what anyone would guess a subagent looks like. The app already has the right mark: the bot badge the subagent settings and the model pickers use for the same concept, so the same delegate reads the same way everywhere. Only the glyph changed. The badge sits in the shared composer-ac-icon slot that file and command rows already occupy, so it inherits their colour and sizing and no CSS was needed; the two group headings, their order, and every other row are untouched. Both rows are labelled for assistive tech, so the icon is what a sighted user reads, and the tests now pin the glyph on each kind. The first assertion I wrote compared aria-hidden, which turned out to be set on both rows by lucide and so distinguished nothing. fixes vastsa#986
Picking a delegate left `@explorer` as ordinary text in the draft. It read as a string the user could take apart, and one keystroke could: a caret inside the token plus a Backspace gives `@explo`, which is both a broken mention and a half-typed handle the resolver would not accept. A delegate is one thing the user picked, so it now becomes the same inline chip a completed file reference becomes, badge and all. That chip is `contentEditable=false`, which is what makes deletion atomic — the caret cannot enter it, so a delete takes the whole mention. Building it surfaced a bug that would have shipped quietly. The chip serializes back to `@name`, and the send-time resolver only reads an `@token` at a start or after whitespace — that rule is what stops `user@host` from reading as a mention. So `look@explorer` was produced for a mention sitting mid-sentence, and the resolver ignored it: the delegation silently did not happen. The agent branch now emits a leading space when text precedes it. File output is byte-for-byte unchanged, and a test pins that so the two never drift together. A delegate's `path` is a `Task` handle, not a location, so `kind` is the discriminant that keeps one off the attachment path in the optimistic transcript row and the prompt builder, and off the image path on draft restore — none of which may receive it and ask the host to read it. A draft restore brings a delegate back as a delegate rather than restyling it as a file chip. fixes vastsa#986
The composer chip was never the whole story. A sent turn showed its typed form as one `chat-command-chip` around the entire draft, so a delegate the user deliberately named read as `@explorer 帮我查找…` — the same weight as the words around it, and nothing like the file reference sitting next to it in the same transcript. Skill invocations already solved this and are the model to follow: the offsets of the tokens the user named are recorded on the message and persisted, and the transcript chips exactly those. So `agentMentions` does the same, through `ui_to_record` / `record_to_ui` beside `skillMentions`. Recording at send time rather than re-resolving on render is what makes this durable. Re-resolving would mean a message losing its chip the day its delegate was removed, and would need the delegation catalog fetched into the transcript. Recorded, a message sent while a delegate existed keeps its chip forever, the same way a Skill chip does today. The chip reuses the `.composer-chip` surface a file reference uses, with the bot badge, and deliberately is not a button: a file chip opens that file, and a delegate names a run, which the transcript already shows above. A stored range that does not line up with the text falls back to the whole draft, so a bad offset can never drop or duplicate characters. fixes vastsa#986
The chip repeated the `@` the user typed, which made a chip read as "@explorer" while the file chip beside it read as "AGENTS.md" — same row, two different weights of token, and the sigil crowded a badge that already says what the thing is. It shows `explorer` now. Scoped to the transcript on purpose. The composer chip and the `@` menu row are the token being chosen or typed, so they keep the `@`; dropping it everywhere would make the menu disagree with the draft. The chip is display only: the stored offsets index the typed form, which still carries the `@` and still reaches the model, and prose that merely contains `@explorer` is untouched text. A test pins each of those so the scope cannot quietly widen. Also fills in the transcript section of the English component spec, which an earlier edit of mine had failed to write — the script asserted the Chinese anchor first and exited before saving. fixes vastsa#986
Send a turn, stop it, edit the message and resend, stop again: the composer came back holding Call the `Task` tool with the agent below before answering… The transcript row's `content` is what the MODEL was given, and for a template, a Skill or an `@agent` mention that is the expanded prompt. The words the user typed live in `command`. The smart-stop recovery read `content` and put that into the composer, so the next send would have carried an instruction addressed to the model back to the model. It only showed up for a resent turn because an ordinary send keeps a renderer-side snapshot, which the recovery prefers. An edit-and-resend records no snapshot, so it fell through to the transcript — and the spec already says the recovery is meant to restore the snapshot "instead of copying serialized message paths back into the textarea", which is the one thing this branch did. Read `command` first and fall back to `content`, which is still correct for an ordinary prompt that was never rewritten. The bug predates the mention work — it has been reachable through `/template` and `/skill` since ADR 0024 — but an `@agent` mention rewrites every such turn, so it stopped being an edge case. Left alone deliberately: the three transcript entry points that start a turn without a snapshot. `retryLastPrompt` routes through `editUserMessage` with the stored rewritten text, so snapshotting there would restore that rewrite instead — moving the defect rather than fixing it. Worth its own change. fixes vastsa#986
Upstream moved since this branch started: ten commits, including an architecture refactor that extracted the store transform helpers into `stores/helpers/store-helpers.ts`. Merged rather than rebased so no history rewrite is needed on a branch that is already pushed. One conflict, in `app-store.ts`, and it is the file this branch already touched. The resolution takes upstream's side for the moved helpers and carries this branch's delegate guard to its new home: `promptAttachmentsFromDraft` no longer turns an `@agent` mention into an attachment, wherever it now lives. The one test asserting that guard followed the move. Re-validated on the merged result: build, typecheck, lint, desktop 2979, host-core 654, and the composer-autocomplete and subagents E2E suites.
This branch has not been deployed
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.
Summary
Let a user name a subagent directly, with
@agentin the composer, and route the turn to that delegate (#986).Before this, a subagent was reachable only when the model chose to call
Task. Work the user already knows is separable — "ask the explorer where the retry policy lives" — had no entry point. The delegation machinery was complete and already had one implementation: the cards, the topology, the permission queue,TaskWaitand the context budget all key off "this turn contains aTaskcall", and none of them asks who initiated it. This adds the missing door rather than a second path.The composer offers the same catalog
Taskis built from, and at send time main rewrites the typed draft into an explicitTaskinstruction while the user's own words stay in the transcript as a chip — the arrangement/skillalready uses (ADR 0024).How it reads
@menu gains an Agents group beside the file rows, each with its own heading and the bot badge used by the subagent settings and the model pickers.@explorerinto@explo.Decisions worth reviewing
@rather than/for the trigger.@already means "reference an entity", and both mentions serialize to an@token.explorerfile gets the file.Taskis registered only there (ADR 0062 §4); Plan and Goal omit the group and refuse a hand-typed mention rather than letting the model answer a request the user meant to delegate./skillaccepts. Enforced dispatch needs a public runtime entry point for a private tool, a new RPC, and a synthetic parent row, and would hand the parent a report it never asked for. Recorded as out of scope in ADR 0308.Two defects found while building it
Both are covered by tests that fail without the fix:
look@explorer, and the send-time resolver deliberately reads an@tokenonly at a start or after whitespace (that is what stopsuser@hostreading as a mention). The delegation silently did not happen. The agent branch now emits a leading space; file output is byte-for-byte unchanged and a test pins that.content— what the model was given — so after send → stop → edit-and-resend → stop the composer came back holdingCall the \Task` tool with the agent below…. The typed text lives incommand. This predates the mention work (reachable via/templateand/skillsince ADR 0024), but an@agent` mention rewrites every turn, so it stopped being an edge case.Verification
pnpm build:js,pnpm --filter @pi-desktop/desktop typecheckpnpm lint(biome + style tokens)pnpm -r --if-present testcargo test -p host-core --lockedcargo fmt --check,cargo clippy -p host-core --all-targetspnpm check:agent-policy,pnpm check:pr-basepnpm test:e2e:composer-autocompletepnpm test:e2e:subagentspnpm test:e2e:bootThe remaining desktop failures are the same timing/network-sensitive tests that fail on
origin/mainin this environment; they are not touched by this branch.Notes for the reviewer
host-coregains one optional field,agentMentions, mapped beside the existingskillMentionsinui_to_record/record_to_ui. Old rows read back with no field and render exactly as before.editUserMessage,retryLastPrompt, regenerate). The recovery now readscommand, so it is correct, butretryLastPromptre-sends the stored rewritten text as a fresh prompt — a separate pre-existing issue I deliberately left alone rather than widening this change.@agentis not persisted as a distinct transcript field on the Rust side beyond the offsets; the durable record is thecommandtext plusagentMentions.Closes #986