From 51ded76b2815204e7670b9fbe49dd0ef07f5f7e9 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:51:46 -0700 Subject: [PATCH 1/2] fix(composer): badge a slash word only when it is a real command A bare `/word` became a command badge on shape alone. The composer fills itself from text in several places -- a paste, a restored draft, a quick message, a reopened queued message, a to-do brief -- and every one of them ran the same heuristic: start of line or after a space, `/` or `$`, a letter, no trailing slash. `/notacommand`, `/etc`, `/nope` all passed, and the user had picked none of them from the menu. What came back was an atomic node: the word could no longer be edited in place, its `/` was gone from the label, and its text was rebuilt on send from the badge's id rather than kept -- which for an id the serializer has to escape (`/foo_`) is not even the same bytes. A badge now requires the token to be one the agent advertises right now, matched exactly: the tokens behind the `/` and `$` menus themselves, so what seeded text may become is precisely what the menu could have inserted. `/rev` is not `/review`, `/Review` is not `/review` (the CLI reads the name it advertised), and with no list yet -- the connection still coming up, or no agent behind the box at all -- nothing is badged. That direction is the safe one: text that stays text is still editable and still sends verbatim. Picking from the menu is untouched, trailing space included, as are badges already in the document; a list that lands mid-compose applies to the next paste and rewrites nothing already written. Reference links keep hydrating regardless -- a `file:`/`codeg:` destination was inserted deliberately and says so. The transcript keeps the heuristic on its own: a sent message is fixed text, and gating it on a list that comes and goes with the connection would re-badge history underneath the reader. --- .../automations/automation-editor.tsx | 1 + .../composer-invocations-popup.test.tsx | 1 + .../automations/composer-invocations.tsx | 18 ++++ .../chat/composer/composer-commands.test.ts | 19 +++- .../chat/composer/composer-commands.ts | 15 ++- .../composer/invocation-reference.test.ts | 23 ++++ .../chat/composer/invocation-reference.ts | 26 +++++ .../chat/composer/plain-text-content.test.ts | 100 +++++++++++++++++- .../chat/composer/plain-text-content.ts | 40 +++++-- .../chat/composer/rich-composer.test.tsx | 31 +++++- .../chat/composer/rich-composer.tsx | 50 +++++++-- .../chat/composer/use-composer-attachments.ts | 15 ++- src/components/chat/message-input.test.tsx | 89 ++++++++++++++++ src/components/chat/message-input.tsx | 16 ++- .../message/user-message-segments.test.ts | 34 ++++++ .../message/user-message-segments.ts | 41 ++++++- .../tasks/task-message-composer.tsx | 10 +- src/lib/invocation-token.ts | 20 ++++ 18 files changed, 510 insertions(+), 39 deletions(-) diff --git a/src/components/automations/automation-editor.tsx b/src/components/automations/automation-editor.tsx index 8baf050fa8..7a88b7d287 100644 --- a/src/components/automations/automation-editor.tsx +++ b/src/components/automations/automation-editor.tsx @@ -379,6 +379,7 @@ export function AutomationEditor({ mentionUiLabels={mentionUiLabels} tabLabels={referenceGroupLabels} mentionAnchorRef={composerBoxRef} + knownInvocations={invocations.knownInvocations} onChange={(text) => { setPrompt(text) invocations.detect() diff --git a/src/components/automations/composer-invocations-popup.test.tsx b/src/components/automations/composer-invocations-popup.test.tsx index f730a5ee7e..0c2ff2cea6 100644 --- a/src/components/automations/composer-invocations-popup.test.tsx +++ b/src/components/automations/composer-invocations-popup.test.tsx @@ -32,6 +32,7 @@ function invocations( isOpen: true, commands, skills: [], + knownInvocations: new Set(commands.map((cmd) => `/${cmd.name}`)), activeIndex: 0, detect: () => {}, onKeyDown: () => false, diff --git a/src/components/automations/composer-invocations.tsx b/src/components/automations/composer-invocations.tsx index 7f1bfdb87b..55684c2063 100644 --- a/src/components/automations/composer-invocations.tsx +++ b/src/components/automations/composer-invocations.tsx @@ -18,6 +18,7 @@ import { type PopupPosition, } from "@/components/chat/composer/suggestion/popup-position" import { + buildKnownInvocations, commandInvocationToken, commandToReference, skillToReference, @@ -26,6 +27,7 @@ import type { ReferenceAttrs } from "@/components/chat/composer/types" import { useAgentSkills } from "@/hooks/use-agent-skills" import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { isImeCompositionKey } from "@/lib/ime-composition" +import type { KnownInvocations } from "@/lib/invocation-token" import { cn } from "@/lib/utils" import type { AgentSkillItem, @@ -53,6 +55,9 @@ export interface ComposerInvocations { isOpen: boolean commands: AvailableCommandInfo[] skills: AgentSkillItem[] + /** Every invocation this menu could offer, for the composer's `knownInvocations` + * — so seeded / pasted text badges exactly what the menu would insert. */ + knownInvocations: KnownInvocations /** Index into the merged [commands, skills] list. */ activeIndex: number /** Re-evaluate the trigger from the editor's current caret (call on change). */ @@ -134,6 +139,18 @@ export function useComposerInvocations({ ) }, [isCodex, open, triggerChar, skills, filter]) + // Built from the FULL lists, not the filtered ones: this answers "is there + // such a command", which the current query has no say in. + const knownInvocations = useMemo( + () => + buildKnownInvocations( + availableCommands, + isCodex ? skills : null, + isCodex ? "$" : "/" + ), + [availableCommands, isCodex, skills] + ) + const count = commands.length + matchedSkills.length // Clamp on read so a shrinking filtered list never points past the end (avoids // a clamping effect / set-state-in-effect). @@ -228,6 +245,7 @@ export function useComposerInvocations({ isOpen: open && count > 0, commands, skills: matchedSkills, + knownInvocations, activeIndex, detect, onKeyDown, diff --git a/src/components/chat/composer/composer-commands.test.ts b/src/components/chat/composer/composer-commands.test.ts index 51f4b14bc9..4048885822 100644 --- a/src/components/chat/composer/composer-commands.test.ts +++ b/src/components/chat/composer/composer-commands.test.ts @@ -268,9 +268,11 @@ describe("restoreBlocksIntoEditor", () => { // docToPromptBlocks emits ONE text block with every badge serialized inline, // so a queue-edit has to parse them back out to show the sender's badges. const text = "run /review on [app.ts](file:///repo/app.ts)" - const attachments = restoreBlocksIntoEditor(editor, [ - { type: "text", text }, - ]) + const attachments = restoreBlocksIntoEditor( + editor, + [{ type: "text", text }], + new Set(["/review"]) + ) expect( JSON.stringify(editor.getJSON()).match(/"type":"reference"/g) ).toHaveLength(2) @@ -279,6 +281,17 @@ describe("restoreBlocksIntoEditor", () => { expect(attachments).toEqual([]) }) + it("restores a queued `/cmd` the agent no longer advertises as its text", () => { + // The message still sends the same bytes; it just stops claiming to be a + // command the agent would recognize. + const text = "run /review on [app.ts](file:///repo/app.ts)" + restoreBlocksIntoEditor(editor, [{ type: "text", text }], new Set()) + expect( + JSON.stringify(editor.getJSON()).match(/"type":"reference"/g) + ).toHaveLength(1) + expect(serialized(editor)).toBe(text) + }) + it("restores every serialized agent link as a badge, losslessly", () => { // No badge is privileged over another: routing is derived backend-side from // the VISIBLE link, so a restored draft sends exactly like the original. diff --git a/src/components/chat/composer/composer-commands.ts b/src/components/chat/composer/composer-commands.ts index 76945f5ab2..69d01a06d7 100644 --- a/src/components/chat/composer/composer-commands.ts +++ b/src/components/chat/composer/composer-commands.ts @@ -1,5 +1,9 @@ import type { Editor } from "@tiptap/core" +import { + NO_KNOWN_INVOCATIONS, + type KnownInvocations, +} from "@/lib/invocation-token" import type { PromptInputBlock } from "@/lib/types" import type { InputAttachment } from "../message-input-attachments" @@ -149,19 +153,22 @@ export function restampSkillPrefixes( * references `docToPromptBlocks` serialized INTO that text (it emits one text * block with every badge inline) come back as badges instead of raw * `[label](uri)` / `/cmd` source — the same treatment paste and the other - * seeding paths get. Lossless: re-serializing the restored badges reproduces the - * block text verbatim. + * seeding paths get, `known` included: a `/cmd` the agent no longer advertises + * comes back as the text it will be sent as, rather than as a badge claiming a + * command that isn't there. Lossless either way: re-serializing the restored + * content reproduces the block text verbatim. */ export function restoreBlocksIntoEditor( editor: Editor, - blocks: PromptInputBlock[] + blocks: PromptInputBlock[], + known: KnownInvocations = NO_KNOWN_INVOCATIONS ): InputAttachment[] { const { segments, attachments } = blocksToRestoredDraft(blocks) let chain = editor.chain().clearContent() for (const segment of segments) { chain = segment.kind === "text" - ? chain.insertContent(textToSeededInlineContent(segment.text)) + ? chain.insertContent(textToSeededInlineContent(segment.text, known)) : chain.insertReference(segment.attrs) } chain.focus("end").run() diff --git a/src/components/chat/composer/invocation-reference.test.ts b/src/components/chat/composer/invocation-reference.test.ts index f652708bbe..e2bde26156 100644 --- a/src/components/chat/composer/invocation-reference.test.ts +++ b/src/components/chat/composer/invocation-reference.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import type { AgentSkillItem, AvailableCommandInfo } from "@/lib/types" import { + buildKnownInvocations, commandInvocationToken, commandToReference, skillToReference, @@ -85,3 +86,25 @@ describe("skillToReference", () => { expect(skillToReference(skill("only-id", ""), "/").label).toBe("only-id") }) }) + +describe("buildKnownInvocations", () => { + it("keys commands and skills by the token they are sent as", () => { + const known = buildKnownInvocations( + [cmd("review"), cmd("$deploy")], + [skill("ship", "Ship")], + "$" + ) + expect([...known].sort()).toEqual(["$deploy", "$ship", "/review"]) + }) + + it("uses `/` for skills when no Codex prefix is given", () => { + expect([...buildKnownInvocations(null, [skill("ship", "Ship")])]).toEqual([ + "/ship", + ]) + }) + + it("is empty for an agent that has advertised nothing yet", () => { + expect(buildKnownInvocations(null).size).toBe(0) + expect(buildKnownInvocations([]).size).toBe(0) + }) +}) diff --git a/src/components/chat/composer/invocation-reference.ts b/src/components/chat/composer/invocation-reference.ts index f0da861ccc..51ab0de087 100644 --- a/src/components/chat/composer/invocation-reference.ts +++ b/src/components/chat/composer/invocation-reference.ts @@ -1,3 +1,4 @@ +import type { KnownInvocations } from "@/lib/invocation-token" import type { AgentSkillItem, AvailableCommandInfo } from "@/lib/types" import type { ReferenceAttrs } from "./types" @@ -52,3 +53,28 @@ export function skillToReference( meta: { invocationPrefix: prefix, scope: skill.scope }, } } + +/** + * The {@link KnownInvocations} of a composer: exactly the tokens its own + * `/`·`$` menu can offer, written the way they are sent — so whatever the menu + * would insert as a badge is also what typed or pasted text is allowed to become + * one, and nothing else is. + * + * `skills` are the on-disk skills behind the `$` menu (Codex only; every other + * agent advertises its skills through `commands` already) and are keyed with the + * prefix that agent triggers them by. + */ +export function buildKnownInvocations( + commands: readonly AvailableCommandInfo[] | null | undefined, + skills?: readonly AgentSkillItem[] | null, + skillPrefix: InvocationPrefix = "/" +): KnownInvocations { + const tokens = new Set() + for (const cmd of commands ?? []) { + if (cmd.name) tokens.add(commandInvocationToken(cmd.name)) + } + for (const skill of skills ?? []) { + if (skill.id) tokens.add(`${skillPrefix}${skill.id}`) + } + return tokens +} diff --git a/src/components/chat/composer/plain-text-content.test.ts b/src/components/chat/composer/plain-text-content.test.ts index 725fa1dc11..b5286a5dbf 100644 --- a/src/components/chat/composer/plain-text-content.test.ts +++ b/src/components/chat/composer/plain-text-content.test.ts @@ -8,6 +8,9 @@ import { textToSeededInlineContent, } from "./plain-text-content" +/** What the agent in these tests advertises: `/review` and `$deploy`, nothing else. */ +const KNOWN = new Set(["/review", "$deploy"]) + describe("decidePastedContent", () => { it("inserts text/plain when the clipboard carries an external HTML fragment", () => { // What a browser puts on the clipboard when a URL is copied from the address @@ -52,6 +55,24 @@ describe("decidePastedContent", () => { ]) }) + it("pastes a slash word the agent does not advertise as prose", () => { + // A regex, a path or a sentence pasted out of a terminal is not a command, + // and a paste is not a choice from the menu. + expect( + decidePastedContent({ html: "", text: "run /notacommand now" }, KNOWN) + ).toBeNull() + expect( + decidePastedContent({ html: "", text: "run /review now" }, KNOWN) + ).toEqual([ + { type: "text", text: "run " }, + { + type: "reference", + attrs: expect.objectContaining({ refType: "skill", id: "review" }), + }, + { type: "text", text: " now" }, + ]) + }) + it("hydrates references when forcing text/plain over an external HTML fragment", () => { const decision = decidePastedContent({ html: "
run [@Codex](codeg://agent/codex)
", @@ -134,7 +155,8 @@ describe("textToSeededInlineContent", () => { // filling one into the composer must show badges, not `[label](uri)` text. expect( textToSeededInlineContent( - "/review [app.ts](file:///repo/app.ts) with [@Codex](codeg://agent/codex)" + "/review [app.ts](file:///repo/app.ts) with [@Codex](codeg://agent/codex)", + KNOWN ) ).toEqual([ { @@ -219,8 +241,8 @@ describe("textToHydratedInlineContent", () => { expect(textToHydratedInlineContent("see /usr/bin for it")).toBeNull() }) - it("hydrates bare `/cmd` and `$skill` tokens, keeping their trigger", () => { - const content = textToHydratedInlineContent("$deploy prod") + it("hydrates an advertised bare `/cmd` / `$skill` token, keeping its trigger", () => { + const content = textToHydratedInlineContent("$deploy prod", KNOWN) expect(content).toEqual([ { type: "reference", @@ -233,7 +255,7 @@ describe("textToHydratedInlineContent", () => { }, { type: "text", text: " prod" }, ]) - expect(textToHydratedInlineContent("/review it")?.[0]).toEqual({ + expect(textToHydratedInlineContent("/review it", KNOWN)?.[0]).toEqual({ type: "reference", attrs: expect.objectContaining({ refType: "skill", @@ -243,6 +265,76 @@ describe("textToHydratedInlineContent", () => { }) }) + describe("bare tokens the agent does not advertise", () => { + it("leaves a token matching no command as prose", () => { + expect( + textToHydratedInlineContent("/notacommand hello", KNOWN) + ).toBeNull() + }) + + it("leaves a prefix of a real command as prose", () => { + // `/rev` is not `/review`, and a menu row nobody picked is not a choice. + expect(textToHydratedInlineContent("/rev it", KNOWN)).toBeNull() + expect(textToHydratedInlineContent("/reviews it", KNOWN)).toBeNull() + }) + + it("matches command names case-sensitively", () => { + // The agent CLI reads the name it advertised; `/Review` is not it. + expect(textToHydratedInlineContent("/Review it", KNOWN)).toBeNull() + }) + + it("leaves paths, mid-word slashes and urls as prose", () => { + for (const text of [ + "/tmp/x is the scratch dir", + "/usr/bin/env python", + "check and/or fix it", + "open http://x now", + "/etc please", + ]) { + expect(textToHydratedInlineContent(text, KNOWN)).toBeNull() + } + }) + + it("badges nothing at all with no advertised list", () => { + // The connection is still coming up (or there is no agent behind this + // box): unverifiable is not the same as valid. + expect(textToHydratedInlineContent("/review it")).toBeNull() + expect(textToSeededInlineContent("/review it")).toEqual([ + { type: "text", text: "/review it" }, + ]) + }) + + it("keeps a reference link in text whose command is unknown", () => { + // Pass 1 is unambiguous — a `file:` link was inserted deliberately — so + // gating the bare token must not cost the badge next to it. + expect( + textToHydratedInlineContent( + "/notacommand [app.ts](file:///repo/app.ts)", + KNOWN + ) + ).toEqual([ + { type: "text", text: "/notacommand " }, + { + type: "reference", + attrs: expect.objectContaining({ uri: "file:///repo/app.ts" }), + }, + ]) + }) + + it("keeps an unknown token's text byte for byte around a known one", () => { + expect( + textToSeededInlineContent("run /nope then /review ok", KNOWN) + ).toEqual([ + { type: "text", text: "run /nope then " }, + { + type: "reference", + attrs: expect.objectContaining({ refType: "skill", id: "review" }), + }, + { type: "text", text: " ok" }, + ]) + }) + }) + it("hydrates session links and maps newlines around badges to hard breaks", () => { expect( textToHydratedInlineContent("re:\n[My chat](codeg://session/42)") diff --git a/src/components/chat/composer/plain-text-content.ts b/src/components/chat/composer/plain-text-content.ts index e64f2976e3..105b129616 100644 --- a/src/components/chat/composer/plain-text-content.ts +++ b/src/components/chat/composer/plain-text-content.ts @@ -1,6 +1,10 @@ import type { JSONContent } from "@tiptap/core" import { parseUserMessageSegments } from "@/components/message/user-message-segments" +import { + NO_KNOWN_INVOCATIONS, + type KnownInvocations, +} from "@/lib/invocation-token" import { referenceToMarkdown } from "./reference-text" import { isEmbeddedReferenceUri } from "./reference-uri" @@ -53,12 +57,20 @@ function isSendDroppedReference(attrs: ReferenceAttrs): boolean { * * Returns null when nothing hydrates (no reference in the text), so callers * can leave a plain paste to ProseMirror's default handling. + * + * A bare `/cmd`·`$skill` token only becomes a badge when it is one of `known` — + * the invocations the agent advertises right now. The default is none: a badge + * in the composer claims the text IS a command, and a claim nothing backs is + * exactly the one this argument exists to stop. Reference LINKS hydrate + * regardless; they carry a `file:`/`codeg:` destination and were deliberately + * inserted. */ export function textToHydratedInlineContent( - text: string + text: string, + known: KnownInvocations = NO_KNOWN_INVOCATIONS ): JSONContent[] | null { if (!text) return null - const segments = parseUserMessageSegments(text) + const segments = parseUserMessageSegments(text, { knownInvocations: known }) const hydratable = segments.some( (segment) => segment.kind === "reference" && !isSendDroppedReference(segment.attrs) @@ -89,9 +101,15 @@ export function textToHydratedInlineContent( * badges immediately instead of only after the message is sent. Hydration is * lossless — the badges re-serialize to exactly the seeded text — so what gets * sent is unchanged either way. + * + * `known` gates the bare tokens exactly as in + * {@link textToHydratedInlineContent}. */ -export function textToSeededInlineContent(text: string): JSONContent[] { - return textToHydratedInlineContent(text) ?? textToInlineContent(text) +export function textToSeededInlineContent( + text: string, + known: KnownInvocations = NO_KNOWN_INVOCATIONS +): JSONContent[] { + return textToHydratedInlineContent(text, known) ?? textToInlineContent(text) } /** @@ -100,10 +118,15 @@ export function textToSeededInlineContent(text: string): JSONContent[] { * a legacy v1 Markdown draft, a queued message's display text, an injected * quick-action/expert template, a saved automation's prompt. */ -export function textToSeededDoc(text: string): JSONContent { +export function textToSeededDoc( + text: string, + known: KnownInvocations = NO_KNOWN_INVOCATIONS +): JSONContent { return { type: "doc", - content: [{ type: "paragraph", content: textToSeededInlineContent(text) }], + content: [ + { type: "paragraph", content: textToSeededInlineContent(text, known) }, + ], } } @@ -151,7 +174,8 @@ export interface ClipboardTextSnapshot { * slice wrapper — a badge must never downgrade to its plain-text token. */ export function decidePastedContent( - snapshot: ClipboardTextSnapshot + snapshot: ClipboardTextSnapshot, + known: KnownInvocations = NO_KNOWN_INVOCATIONS ): JSONContent[] | null { // Copied from within a ProseMirror editor: defer so its native HTML round-trip // restores structure/hard breaks/badges exactly (see the doc comment). @@ -161,7 +185,7 @@ export function decidePastedContent( if (snapshot.html.includes("data-reference")) return null // Nothing sensible to insert without a text/plain flavor, so defer. if (!snapshot.text) return null - const hydrated = textToHydratedInlineContent(snapshot.text) + const hydrated = textToHydratedInlineContent(snapshot.text, known) // An external rich fragment must insert its plain-text flavor even when // nothing hydrates (never the HTML); a plain-only clipboard without // references keeps ProseMirror's default paste. diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 0ac159997f..16621ae88a 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -412,7 +412,7 @@ describe("RichComposer text paste (plain-text schema)", () => { }) it("hydrates serialized references in a plain-text paste into badges", async () => { - const { ref } = await mount() + const { ref } = await mount({ knownInvocations: new Set(["$deploy"]) }) act(() => ref.current?.focus()) const dom = ref.current?.getEditor()?.view.dom as HTMLElement // The wire form of a sent message (file link + Codex `$` skill token): the @@ -428,6 +428,35 @@ describe("RichComposer text paste (plain-text schema)", () => { expect(ref.current?.getText()).toBe(wire) }) + it("pastes a slash word the agent does not advertise as editable text", async () => { + const { ref } = await mount({ knownInvocations: new Set(["/review"]) }) + act(() => ref.current?.focus()) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + dispatchPaste(dom, { text: "try /notacommand on /tmp/x" }) + expect(JSON.stringify(ref.current?.getJSON())).not.toContain( + '"type":"reference"' + ) + expect(ref.current?.getText()).toBe("try /notacommand on /tmp/x") + }) + + it("seeds a slash word as text until the agent's list says it is a command", async () => { + // The list arrives with the connection, so a composer that seeds before it + // lands must not guess — and must honor it once it is there. + const { ref, rerender } = await mount() + act(() => ref.current?.setText("/review it")) + expect(JSON.stringify(ref.current?.getJSON())).not.toContain( + '"type":"reference"' + ) + expect(ref.current?.getText()).toBe("/review it") + + rerender() + act(() => ref.current?.setText("/review it")) + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"refType":"skill"' + ) + expect(ref.current?.getText()).toBe("/review it") + }) + it("does not insert text when the host consumes the paste as files", async () => { const onPasteFiles = vi.fn(() => true) const { ref } = await mount({ onPasteFiles }) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 7d1a646bc1..4e897834ae 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -16,6 +16,10 @@ import { EditorContent, useEditor } from "@tiptap/react" import { exitSuggestion } from "@tiptap/suggestion" import { isImeCompositionKey } from "@/lib/ime-composition" +import { + NO_KNOWN_INVOCATIONS, + type KnownInvocations, +} from "@/lib/invocation-token" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { cn } from "@/lib/utils" @@ -153,6 +157,20 @@ export interface RichComposerProps { * root, which is the same box for a host that wraps nothing else around it. */ mentionAnchorRef?: RefObject + /** + * The invocations the host's `/`·`$` menu can offer right now (see + * {@link "./invocation-reference".buildKnownInvocations}). Seeded and pasted + * text turns a bare `/cmd`·`$skill` token into a command badge only when it is + * one of these; anything else stays editable prose. Omit — or leave empty + * while the agent's list is still on its way — and no bare token is ever + * badged, which is the safe direction: text that stays text sends exactly as + * written. + * + * Read at event time, so a list that lands mid-compose applies to the next + * paste without recreating the editor. Badges already in the document are + * never revisited. + */ + knownInvocations?: KnownInvocations /** * Key binding (matchShortcutEvent form) that sends the message. Default * `"enter"`. When set to a non-Enter binding, a plain Enter inserts a newline. @@ -224,6 +242,7 @@ export const RichComposer = forwardRef( mentionUiLabels, tabLabels, mentionAnchorRef, + knownInvocations, submitShortcut, newlineShortcut, isExternalMenuOpen, @@ -245,6 +264,10 @@ export const RichComposer = forwardRef( // installed) is gated on whether mentions are currently enabled — robust to // the prop being added/removed after the editor is created once. const referenceSearchRef = useRef(referenceSearch) + // Read at event time (paste, seed) rather than baked into the editor, so a + // command list that arrives after the connection comes up applies without + // rebuilding the editor — and without disturbing what is already typed. + const knownInvocationsRef = useRef(knownInvocations) const submitShortcutRef = useRef(submitShortcut) const newlineShortcutRef = useRef(newlineShortcut) const isExternalMenuOpenRef = useRef(isExternalMenuOpen) @@ -264,6 +287,7 @@ export const RichComposer = forwardRef( onBlurRef.current = onBlur onReadyRef.current = onReady referenceSearchRef.current = referenceSearch + knownInvocationsRef.current = knownInvocations submitShortcutRef.current = submitShortcut newlineShortcutRef.current = newlineShortcut isExternalMenuOpenRef.current = isExternalMenuOpen @@ -314,6 +338,12 @@ export const RichComposer = forwardRef( const placeholderRef = useRef(placeholder) const getPlaceholder = useCallback(() => placeholderRef.current ?? "", []) + /** The invocations badge-able right now (see the `knownInvocations` prop). */ + const known = useCallback( + () => knownInvocationsRef.current ?? NO_KNOWN_INVOCATIONS, + [] + ) + const editor = useEditor({ // Static export / SSR safety: never render on the server. immediatelyRender: false, @@ -413,10 +443,13 @@ export const RichComposer = forwardRef( const editor = editorInstanceRef.current const clipboard = event.clipboardData if (!editor || !clipboard) return false - const inline = decidePastedContent({ - html: clipboard.getData("text/html"), - text: clipboard.getData("text/plain"), - }) + const inline = decidePastedContent( + { + html: clipboard.getData("text/html"), + text: clipboard.getData("text/plain"), + }, + known() + ) if (!inline) return false editor.chain().insertContent(inline).run() return true @@ -426,7 +459,7 @@ export const RichComposer = forwardRef( onCreate: ({ editor }) => { editorInstanceRef.current = editor if (defaultText) { - editor.commands.setContent(textToSeededDoc(defaultText), { + editor.commands.setContent(textToSeededDoc(defaultText, known()), { emitUpdate: false, }) } @@ -465,7 +498,8 @@ export const RichComposer = forwardRef( ref, (): RichComposerHandle => ({ getText: () => (editor ? serializeDocToText(editor.state.doc) : ""), - setText: (text) => editor?.commands.setContent(textToSeededDoc(text)), + setText: (text) => + editor?.commands.setContent(textToSeededDoc(text, known())), setDoc: (doc) => editor?.commands.setContent(doc), clear: () => editor?.commands.clearContent(true), focus: () => editor?.commands.focus("end"), @@ -511,7 +545,7 @@ export const RichComposer = forwardRef( editor ?.chain() .focus() - .insertContent(textToSeededInlineContent(text)) + .insertContent(textToSeededInlineContent(text, known())) .run() }, insertReference: (attrs) => { @@ -519,7 +553,7 @@ export const RichComposer = forwardRef( }, getEditor: () => editor ?? null, }), - [editor] + [editor, known] ) const closeMention = useCallback(() => { diff --git a/src/components/chat/composer/use-composer-attachments.ts b/src/components/chat/composer/use-composer-attachments.ts index d117248f89..d73f6810ac 100644 --- a/src/components/chat/composer/use-composer-attachments.ts +++ b/src/components/chat/composer/use-composer-attachments.ts @@ -39,6 +39,7 @@ import { hasFileTreeDragType, readFileTreeDragPayload, } from "@/lib/file-tree-dnd" +import type { KnownInvocations } from "@/lib/invocation-token" import { isDesktop, openFileDialog } from "@/lib/platform" import { buildFileUri, @@ -164,8 +165,14 @@ export interface ComposerAttachments { /** Replay a stored `PromptInputBlock[]` into this composer: prose + file * badges inline, images into the strip, bytes-bearing resources re-registered - * behind fresh sentinel badges. Replaces whatever the editor held. */ - hydrateFromBlocks: (editor: Editor, blocks: PromptInputBlock[]) => void + * behind fresh sentinel badges. Replaces whatever the editor held. `known` + * is the host's advertised invocations, gating bare `/cmd` tokens in the + * prose (see {@link restoreBlocksIntoEditor}). */ + hydrateFromBlocks: ( + editor: Editor, + blocks: PromptInputBlock[], + known?: KnownInvocations + ) => void removeAttachment: (id: string) => void clearAttachments: () => void /** The image blocks for a send, in the encoding the agent accepts. Inline @@ -1260,9 +1267,9 @@ export function useComposerAttachments({ // re-registered in the payload map (their original sentinel uri was never // serialized, so each gets a fresh one). const hydrateFromBlocks = useCallback( - (editor: Editor, blocks: PromptInputBlock[]) => { + (editor: Editor, blocks: PromptInputBlock[], known?: KnownInvocations) => { embeddedPayloadsRef.current.clear() - const restored = restoreBlocksIntoEditor(editor, blocks) + const restored = restoreBlocksIntoEditor(editor, blocks, known) setAttachments( restored.filter((a): a is ImageInputAttachment => a.type === "image") ) diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 2b595b38f1..42b37f7626 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -1012,6 +1012,95 @@ describe("MessageInput slash menu while the agent connects", () => { }) }) +describe("MessageInput slash badges", () => { + afterEach(() => { + cleanup() + composerHandle.current = null + }) + + const COMMANDS = [{ name: "compact", description: "Compact the thread" }] + + async function mount( + props: Partial> = {} + ) { + renderInput({ availableCommands: COMMANDS, ...props }) + await waitFor( + () => expect(composerHandle.current?.getEditor()).toBeTruthy(), + { timeout: 5000 } + ) + const handle = composerHandle.current + const editor = handle?.getEditor() + if (!handle || !editor) throw new Error("composer editor not mounted") + return { handle, editor } + } + + function press(editor: Editor, key: string) { + act(() => { + ;(editor.view.dom as HTMLElement).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }) + ) + }) + } + + for (const key of ["Enter", "Tab"]) { + it(`still badges the command picked from the menu with ${key}`, async () => { + const { handle, editor } = await mount() + act(() => { + editor.commands.insertContent("/comp") + }) + await screen.findByTestId("slash-menu") + press(editor, key) + await waitFor(() => + expect(JSON.stringify(handle.getJSON())).toContain('"type":"reference"') + ) + // The badge still brings its trailing space, so the next word is typed + // clear of it. + expect(handle.getText()).toBe("/compact ") + }) + } + + it("badges the command clicked in the menu", async () => { + const { handle, editor } = await mount() + act(() => { + editor.commands.insertContent("/comp") + }) + const menu = await screen.findByTestId("slash-menu") + fireEvent.mouseDown(within(menu).getByText("/compact")) + await waitFor(() => + expect(JSON.stringify(handle.getJSON())).toContain('"type":"reference"') + ) + expect(handle.getText()).toBe("/compact ") + }) + + it("leaves a seeded slash word the agent never advertised as plain text", async () => { + const { handle } = await mount() + act(() => { + handle.setText("/notacommand on /tmp/x and and/or") + }) + expect(JSON.stringify(handle.getJSON())).not.toContain('"type":"reference"') + expect(handle.getText()).toBe("/notacommand on /tmp/x and and/or") + }) + + it("badges a seeded token that IS one of the agent's commands", async () => { + const { handle } = await mount() + act(() => { + handle.setText("/compact the thread") + }) + expect(JSON.stringify(handle.getJSON())).toContain('"refType":"skill"') + // Same bytes on the wire either way — only the composer's rendering differs. + expect(handle.getText()).toBe("/compact the thread") + }) + + it("leaves a seeded command alone for an agent that has none", async () => { + const { handle } = await mount({ availableCommands: [] }) + act(() => { + handle.setText("/compact the thread") + }) + expect(JSON.stringify(handle.getJSON())).not.toContain('"type":"reference"') + expect(handle.getText()).toBe("/compact the thread") + }) +}) + describe("MessageInput mid-turn send (live-feedback channel)", () => { afterEach(() => { cleanup() diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 7679ab868f..b4eab48daa 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -120,6 +120,7 @@ import { restampSkillPrefixes, } from "@/components/chat/composer/composer-commands" import { + buildKnownInvocations, commandInvocationToken, commandToReference, skillToReference, @@ -362,6 +363,14 @@ export function MessageInput({ // only ever saw global skills in the `$` autocomplete. const availableSkills = useAgentSkills(skillAgentType, defaultPath ?? null) const skillPrefix = agentType === "codex" ? "$" : "/" + // Exactly what the `/`·`$` menu below can offer. Seeding or pasting text turns + // a bare `/cmd`·`$skill` token into a badge only when it is on this list, so + // prose the agent has no command for stays prose. + const knownInvocations = useMemo( + () => + buildKnownInvocations(availableCommands, availableSkills, skillPrefix), + [availableCommands, availableSkills, skillPrefix] + ) const { shortcuts } = useShortcutSettings() const effectiveDraftStorageKey = draftStorageKey ?? null const resolvedPlaceholder = placeholder ?? t("askAnything") @@ -529,7 +538,7 @@ export function MessageInput({ const editor = ed.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { // Full fidelity: restore inline badges + images from the blocks. - hydrateFromBlocks(editor, editingDraftBlocks) + hydrateFromBlocks(editor, editingDraftBlocks, knownInvocations) } else if (editingDraftText != null) { ed.setText(editingDraftText) } @@ -553,6 +562,7 @@ export function MessageInput({ editingDraftBlocks, effectiveDraftStorageKey, hydrateFromBlocks, + knownInvocations, ]) // Focus the composer the moment the editor exists and this tab is active, so @@ -590,7 +600,7 @@ export function MessageInput({ const raf = requestAnimationFrame(() => { const editor = editorRef.current?.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { - hydrateFromBlocks(editor, editingDraftBlocks) + hydrateFromBlocks(editor, editingDraftBlocks, knownInvocations) } else if (editingDraftText != null) { editorRef.current?.setText(editingDraftText) } @@ -607,6 +617,7 @@ export function MessageInput({ editingDraftText, editingDraftBlocks, hydrateFromBlocks, + knownInvocations, ]) useEffect(() => { @@ -1943,6 +1954,7 @@ export function MessageInput({ // the same box the `/` menu hangs off (this container), so the // two read as one affordance. mentionAnchorRef={containerRef} + knownInvocations={knownInvocations} onChange={handleComposerChange} onReady={handleComposerReady} onSubmit={handleSend} diff --git a/src/components/message/user-message-segments.test.ts b/src/components/message/user-message-segments.test.ts index 785dc8f579..04df35d27b 100644 --- a/src/components/message/user-message-segments.test.ts +++ b/src/components/message/user-message-segments.test.ts @@ -122,6 +122,40 @@ describe("parseUserMessageSegments", () => { { kind: "text", text: "a/b/c" }, ]) }) + + describe("restricted to an agent's advertised invocations", () => { + const knownInvocations = new Set(["/review"]) + + it("badges only a token on the list", () => { + expect( + parseUserMessageSegments("run /review please", { knownInvocations }) + ).toHaveLength(3) + expect( + parseUserMessageSegments("run /notacommand please", { + knownInvocations, + }) + ).toEqual([{ kind: "text", text: "run /notacommand please" }]) + }) + + it("keeps unbadged tokens inside one contiguous text run", () => { + // Two skipped tokens must not split the prose into three segments the + // renderer would then have to stitch back together. + expect( + parseUserMessageSegments("/a and /b", { knownInvocations }) + ).toEqual([{ kind: "text", text: "/a and /b" }]) + }) + + it("still badges the reference links around an unknown token", () => { + expect( + parseUserMessageSegments("/nope [a.ts](file:///a.ts)", { + knownInvocations, + }) + ).toEqual([ + { kind: "text", text: "/nope " }, + { kind: "reference", attrs: expect.objectContaining({ id: "a.ts" }) }, + ]) + }) + }) }) // Guardrail: the render tokenizer must invert referenceToMarkdown (the wire diff --git a/src/components/message/user-message-segments.ts b/src/components/message/user-message-segments.ts index 642bd9bc75..b6f6294740 100644 --- a/src/components/message/user-message-segments.ts +++ b/src/components/message/user-message-segments.ts @@ -1,6 +1,9 @@ import { parseCodegReferenceUri } from "@/components/chat/composer/reference-uri" import type { ReferenceAttrs } from "@/components/chat/composer/types" -import { INVOCATION_TOKEN_RE } from "@/lib/invocation-token" +import { + INVOCATION_TOKEN_RE, + type KnownInvocations, +} from "@/lib/invocation-token" import { tokenizeReferenceLinks, unescapeReferenceLabel, @@ -28,13 +31,25 @@ const REFERENCE_SCHEME = /^(?:file:|codeg:)/i * The badge label drops the literal `/`·`$` prefix (the parser strips it) so a * sent invocation token renders identically to the composer's inline badge, * which shows the bare command/skill name. + * + * `known`, when given, is the list of invocations the agent actually advertises, + * and a token outside it stays literal text. */ -function pushProseSegments(value: string, out: UserMessageSegment[]): void { +function pushProseSegments( + value: string, + out: UserMessageSegment[], + known: KnownInvocations | undefined +): void { INVOCATION_TOKEN_RE.lastIndex = 0 let lastIndex = 0 let match: RegExpExecArray | null while ((match = INVOCATION_TOKEN_RE.exec(value)) !== null) { const token = match[2] + // Shape alone says nothing about whether this is a command; when the caller + // knows the real list, the token has to be on it. Leaving the token inside + // the current run (rather than pushing it as its own text segment) keeps + // prose that badges nothing as one contiguous segment. + if (known && !known.has(token)) continue const tokenStart = match.index + match[1].length if (tokenStart > lastIndex) { out.push({ kind: "text", text: value.slice(lastIndex, tokenStart) }) @@ -57,6 +72,17 @@ function pushProseSegments(value: string, out: UserMessageSegment[]): void { } } +export interface UserMessageSegmentOptions { + /** + * Restrict pass 2 below to the invocations an agent really advertises — the + * composer passes it so seeded/pasted text cannot invent a command. Omitting + * it keeps the bare-token heuristic on its own, which is what the transcript + * does: a sent message is already fixed text, and a list that arrives with the + * connection would otherwise re-badge history underneath the reader. + */ + knownInvocations?: KnownInvocations +} + /** * Parse a sent user-message text string into ordered render segments: literal * prose (line breaks preserved by the renderer) interleaved with the five @@ -72,8 +98,15 @@ function pushProseSegments(value: string, out: UserMessageSegment[]): void { * * Deliberately NOT Markdown: headings/bold/lists/code/tables in the text stay * literal, matching the plain-text composer. + * + * {@link UserMessageSegmentOptions.knownInvocations} narrows pass 2. Pass 1 is + * unaffected either way — a `file:`/`codeg:` link is unambiguous. */ -export function parseUserMessageSegments(text: string): UserMessageSegment[] { +export function parseUserMessageSegments( + text: string, + options?: UserMessageSegmentOptions +): UserMessageSegment[] { + const known = options?.knownInvocations const out: UserMessageSegment[] = [] for (const token of tokenizeReferenceLinks(text)) { if (token.type === "link") { @@ -92,7 +125,7 @@ export function parseUserMessageSegments(text: string): UserMessageSegment[] { out.push({ kind: "text", text: token.raw }) continue } - pushProseSegments(token.value, out) + pushProseSegments(token.value, out, known) } return out } diff --git a/src/components/tasks/task-message-composer.tsx b/src/components/tasks/task-message-composer.tsx index b26ba61d94..ae0de2c0dd 100644 --- a/src/components/tasks/task-message-composer.tsx +++ b/src/components/tasks/task-message-composer.tsx @@ -240,6 +240,13 @@ export function TaskMessageComposer({ // the editor exists; `defaultBlocks` is read at that moment and not watched, // matching `defaultText` — the box is uncontrolled after mount. const hydratedRef = useRef(false) + // Read inside the deferred frame rather than captured with the callback: the + // agent probe that fills the list can still be in flight at mount, and a brief + // holding a real `/cmd` should come back as that command's badge. + const knownInvocationsRef = useRef(invocations.knownInvocations) + useEffect(() => { + knownInvocationsRef.current = invocations.knownInvocations + }, [invocations.knownInvocations]) const handleReady = useCallback(() => { if (hydratedRef.current) return hydratedRef.current = true @@ -250,7 +257,7 @@ export function TaskMessageComposer({ requestAnimationFrame(() => { const live = editorRef.current?.getEditor() if (!live) return - attach.hydrateFromBlocks(live, defaultBlocks) + attach.hydrateFromBlocks(live, defaultBlocks, knownInvocationsRef.current) onChange(editorRef.current?.getText() ?? "") }) // Mount-time seed: re-running on a new `defaultBlocks` identity would @@ -320,6 +327,7 @@ export function TaskMessageComposer({ tabLabels={groupLabels} // Same box the `/` menu hangs off, so both panels span the composer. mentionAnchorRef={containerRef} + knownInvocations={invocations.knownInvocations} submitShortcut={submitShortcut} newlineShortcut={newlineShortcut} onReady={handleReady} diff --git a/src/lib/invocation-token.ts b/src/lib/invocation-token.ts index d152c153f2..cb553081de 100644 --- a/src/lib/invocation-token.ts +++ b/src/lib/invocation-token.ts @@ -14,3 +14,23 @@ // boundary (start-of-text or the whitespace char), [2] = the token incl. prefix. export const INVOCATION_TOKEN_RE = /(^|\s)([/$][A-Za-z][A-Za-z0-9_-]*)(?![/\w-])/g + +/** + * The literal invocation tokens (`/review`, `$deploy` — prefix included) the + * current agent actually advertises, so a `/word` in free prose can be checked + * against something real instead of being trusted on shape alone. + * + * The regex above is a shape test, and shape is all `/notacommand` needs to pass + * it. Membership here is what the composer requires before turning such a token + * into a badge, matched EXACTLY: a prefix of a real command (`/rev` for + * `/review`) is not that command, and names are case-sensitive because that is + * how the agent CLI reads them. + * + * An empty set is the honest answer while the connection is coming up (or for a + * surface with no agent behind it), and it is also the safe one: text that stays + * text is still editable, and sends byte for byte the way it was written. + */ +export type KnownInvocations = ReadonlySet + +/** No advertised invocation: every bare `/word` / `$word` stays literal text. */ +export const NO_KNOWN_INVOCATIONS: KnownInvocations = new Set() From 86b4bb7a323077e5b9d5f5a3db85f9747a8e5245 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 14 Sep 2026 21:23:20 +0800 Subject: [PATCH 2/2] fix(composer): read the advertised-command list at restore time, not from deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft / queue-edit restore claims its one-shot guard synchronously and mutates the editor in a rAF whose cleanup cancels that frame, so a dependency that changes identity in between cancels the restore and then bails on the already-claimed guard — the composer comes back empty. `knownInvocations` is exactly such a value: it is a fresh Set on every agent re-advertise, and on every render for a host passing `availableCommands={conn.availableCommands ?? []}`. Keep it in a ref read inside the frame instead, matching the task composer, which also makes it the list as of the moment the content is seeded. --- src/components/chat/message-input.test.tsx | 102 +++++++++++++++++++++ src/components/chat/message-input.tsx | 27 +++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 42b37f7626..7d21880ce6 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -1101,6 +1101,108 @@ describe("MessageInput slash badges", () => { }) }) +// The queue-edit / draft restore claims its one-shot guard synchronously but +// mutates the editor in a rAF whose cleanup cancels that frame. Anything in the +// effect's dependency array that changes identity in between therefore cancels +// the restore and then bails on the already-claimed guard — nothing is ever +// restored. The advertised-command list is exactly such a value: it lands with +// the ACP connection, and the Set built from it is fresh every time. +describe("MessageInput queue-edit restore vs. a late command list", () => { + afterEach(() => { + cleanup() + composerHandle.current = null + vi.unstubAllGlobals() + }) + + /** Hold every rAF callback so the test decides when the frame runs. */ + function captureFrames(): { flush: () => void } { + const frames: (FrameRequestCallback | null)[] = [] + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => + frames.push(cb) + ) + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + frames[id - 1] = null + }) + return { + flush: () => { + act(() => { + // Indexed, not iterated: a callback may queue another frame. + for (let i = 0; i < frames.length; i++) { + const cb = frames[i] + frames[i] = null + cb?.(0) + } + }) + }, + } + } + + /** The identity the ACP connection replaces once it advertises. */ + const NO_COMMANDS: React.ComponentProps< + typeof MessageInput + >["availableCommands"] = [] + + function renderAgain( + view: ReturnType, + props: Partial> + ) { + view.rerender( + + + + ) + } + + it("restores the queued message when the commands land before its frame", async () => { + const { flush } = captureFrames() + const editing = { + isEditingQueueItem: true, + editingItemId: "q1", + editingDraftBlocks: [{ type: "text" as const, text: "queued prose" }], + } + const view = renderInput({ availableCommands: NO_COMMANDS, ...editing }) + await waitFor( + () => expect(composerHandle.current?.getEditor()).toBeTruthy(), + { timeout: 5000 } + ) + // The connection comes up: a brand-new command list, and so a brand-new + // `knownInvocations` Set, while the restore's frame is still pending. + renderAgain(view, { + availableCommands: [{ name: "compact", description: "Compact" }], + ...editing, + }) + flush() + expect(composerHandle.current?.getText()).toBe("queued prose") + }) + + // The "re-edit a DIFFERENT queued item" restore is a second effect with its + // own one-shot guard (the last hydrated item id), so it needs its own case. + it("restores the next queued item picked while its frame is pending", async () => { + const { flush } = captureFrames() + const view = renderInput({ availableCommands: NO_COMMANDS }) + await waitFor( + () => expect(composerHandle.current?.getEditor()).toBeTruthy(), + { timeout: 5000 } + ) + flush() + + // The user clicks "edit" on a queued message… + const editing = { + isEditingQueueItem: true, + editingItemId: "q2", + editingDraftBlocks: [{ type: "text" as const, text: "the next one" }], + } + renderAgain(view, { availableCommands: NO_COMMANDS, ...editing }) + // …and the command list lands before that restore's frame runs. + renderAgain(view, { + availableCommands: [{ name: "compact", description: "Compact" }], + ...editing, + }) + flush() + expect(composerHandle.current?.getText()).toBe("the next one") + }) +}) + describe("MessageInput mid-turn send (live-feedback channel)", () => { afterEach(() => { cleanup() diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index b4eab48daa..5be9447495 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -371,6 +371,19 @@ export function MessageInput({ buildKnownInvocations(availableCommands, availableSkills, skillPrefix), [availableCommands, availableSkills, skillPrefix] ) + // The hydration effects below read the list through this ref inside their + // deferred frame, never from their dependency array. `buildKnownInvocations` + // mints a fresh Set whenever the agent re-advertises (and on every render for + // a host that passes `availableCommands={conn.availableCommands ?? []}`), and + // those effects claim a one-shot guard synchronously but do the restore in a + // rAF whose cleanup cancels it: a new identity landing in that gap would + // cancel the frame and then bail on the already-claimed guard, dropping the + // draft entirely. Reading it late is also the more accurate answer — it is + // whatever the agent advertises at the moment the content is actually seeded. + const knownInvocationsRef = useRef(knownInvocations) + useEffect(() => { + knownInvocationsRef.current = knownInvocations + }, [knownInvocations]) const { shortcuts } = useShortcutSettings() const effectiveDraftStorageKey = draftStorageKey ?? null const resolvedPlaceholder = placeholder ?? t("askAnything") @@ -538,7 +551,11 @@ export function MessageInput({ const editor = ed.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { // Full fidelity: restore inline badges + images from the blocks. - hydrateFromBlocks(editor, editingDraftBlocks, knownInvocations) + hydrateFromBlocks( + editor, + editingDraftBlocks, + knownInvocationsRef.current + ) } else if (editingDraftText != null) { ed.setText(editingDraftText) } @@ -562,7 +579,6 @@ export function MessageInput({ editingDraftBlocks, effectiveDraftStorageKey, hydrateFromBlocks, - knownInvocations, ]) // Focus the composer the moment the editor exists and this tab is active, so @@ -600,7 +616,11 @@ export function MessageInput({ const raf = requestAnimationFrame(() => { const editor = editorRef.current?.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { - hydrateFromBlocks(editor, editingDraftBlocks, knownInvocations) + hydrateFromBlocks( + editor, + editingDraftBlocks, + knownInvocationsRef.current + ) } else if (editingDraftText != null) { editorRef.current?.setText(editingDraftText) } @@ -617,7 +637,6 @@ export function MessageInput({ editingDraftText, editingDraftBlocks, hydrateFromBlocks, - knownInvocations, ]) useEffect(() => {