From 045811f5beb5419644a9c1c73b5ccca41171e83c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 22:55:18 -0500 Subject: [PATCH 1/3] fix(context): keep @ref tokens inline instead of stripping them from the prompt Expanding an @file:/@folder: reference deleted the token from the message it was typed in, leaving a hole in the sentence and no anchor for clients to chip. The attached context block still names each ref, so nothing is lost by leaving the token where the user put it. --- agent/context_references.py | 21 ++++++--------------- tests/agent/test_context_references.py | 5 +++-- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/agent/context_references.py b/agent/context_references.py index 8981aa472f50..ab370a5a5924 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -213,8 +213,12 @@ async def preprocess_context_references_async( f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})." ) - stripped = _remove_reference_tokens(message, refs) - final = stripped + # Leave the `@file:`/`@folder:` tokens where the user typed them. The token + # IS the reference, not scaffolding around it: clients render each one as an + # inline chip, so stripping them left a sentence with a hole in it ("review + # and ship") and made the desktop re-derive the refs from the attached block + # to show them as a detached list above the prose. + final = message if warnings: final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings) if blocks: @@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None return _strip_reference_wrappers(value), None, None -def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str: - pieces: list[str] = [] - cursor = 0 - for ref in refs: - pieces.append(message[cursor:ref.start]) - cursor = ref.end - pieces.append(message[cursor:]) - text = "".join(pieces) - text = re.sub(r"\s{2,}", " ", text) - text = re.sub(r"\s+([,.;:!?])", r"\1", text) - return text.strip() - - def _is_binary_file(path: Path) -> bool: mime, _ = mimetypes.guess_type(path.name) if mime and not mime.startswith("text/") and not any( diff --git a/tests/agent/test_context_references.py b/tests/agent/test_context_references.py index 51f66fb937af..43242706a1b9 100644 --- a/tests/agent/test_context_references.py +++ b/tests/agent/test_context_references.py @@ -112,8 +112,9 @@ def test_expand_file_range_and_folder_listing(sample_repo: Path): ) assert result.expanded - assert "Review and" in result.message - assert "Review @file:src/main.py:1-2" not in result.message + # The typed `@` tokens stay in the prose — clients render each one as an + # inline chip where the user put it, rather than a detached list. + assert result.message.startswith("Review @file:src/main.py:1-2 and @folder:src/") assert "--- Attached Context ---" in result.message assert "def alpha():" in result.message assert "return 'changed'" in result.message From 73f8ddbb8b69c270b7bd87dc5aaf451f21caf48f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 22:55:24 -0500 Subject: [PATCH 2/3] fix(desktop): stop hoisting an @ ref that is already inline in the prose displayContentForMessage re-derived every ref from the attached context block and re-emitted it as a detached list above the text. Now that the token survives expansion, that list duplicated the inline chip. Hoist only the refs the prose is missing, so turns persisted by an older backend still render their chips. --- apps/desktop/src/lib/chat-messages.test.ts | 13 +++++++++++++ apps/desktop/src/lib/chat-messages.ts | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 6ddabbe9b38b..d6bfd77525a4 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -220,6 +220,19 @@ describe('toChatMessages', () => { expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook') }) + it('leaves an inline @ ref in place instead of hoisting a duplicate', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: + 'summarize @file:`src/main.ts` for me\n\n--- Attached Context ---\n\nšŸ“„ @file:`src/main.ts` (10 tokens)\n```ts\nconst x = 1\n```', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('summarize @file:`src/main.ts` for me') + }) + it('projects durable timeline kinds without inspecting their text', () => { const messages = toChatMessages([ { role: 'user', content: 'real user turn', timestamp: 1 }, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index f0d8e2b7c641..13cca5b511b3 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -311,7 +311,12 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown const attachedContext = textContent.slice(marker.index + marker[0].length) const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))] - return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText + // The prose keeps the `@file:` token the user typed, so it already chips in + // place. Only hoist a ref the prose is missing — a turn persisted by an older + // backend that stripped the tokens. Re-listing an inline ref would chip twice. + const missing = refs.filter(ref => !visibleText.includes(ref)) + + return [missing.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText } function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null { From 90797eb224551f6681967ca312a05d72e9eebb9e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 23:13:32 -0500 Subject: [PATCH 3/3] fix(desktop): chip a bare @path into the ref it means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab-descending into a folder from the @ popover re-types the token as a bare `@apps/desktop/` so the next complete.path lists its children. That is right while typing, but a bare token is not a reference — REFERENCE_PATTERN only matches @kind:value — so a draft sent with one rendered as plain text and attached nothing at all. Promote it to @file:/@folder: on the way out, the same shape url-refs.ts uses for bare links: on the committing space, on paste, and on submit for a path that never got its space. A '/' is required so a handle like @teknium1 is never mistaken for a path. --- .../composer/hooks/use-composer-submit.ts | 6 +- apps/desktop/src/app/chat/composer/index.tsx | 15 ++- .../src/app/chat/composer/path-refs.test.ts | 103 ++++++++++++++++++ .../src/app/chat/composer/path-refs.ts | 103 ++++++++++++++++++ .../thread/user-edit-composer.tsx | 12 +- 5 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/path-refs.test.ts create mode 100644 apps/desktop/src/app/chat/composer/path-refs.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index fff0b8153fcd..9d34ddfca3f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -9,6 +9,7 @@ import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-qu import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' +import { pathifyRefs } from '../path-refs' import { composerPlainText } from '../rich-editor' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -139,7 +140,10 @@ export function useComposerSubmit({ } } - const text = draftRef.current + // A path that never got its committing space (`@apps/desktop/` left by a Tab + // descend, then Enter) is still the reference the user picked — promote it + // on the way out so it attaches instead of submitting as inert text. + const text = pathifyRefs(draftRef.current) const payloadPresent = text.trim().length > 0 || attachments.length > 0 // A clarify card parked on this session owns the turn: the agent is blocked diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 18aa1b97801a..356767d57e39 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -50,6 +50,7 @@ import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -449,9 +450,9 @@ export function ChatBar({ // Links in the paste land as `@url:` chips rather than a wall of URL text — // the same reference the "Add URL" dialog inserts, parsed in place so a link - // mid-sentence keeps its position. + // mid-sentence keeps its position. Bare `@path` tokens promote the same way. recordUndoPoint() - insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText)) + insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText))) scheduleFlushEditorToDraft(event.currentTarget) } @@ -519,6 +520,16 @@ export function ChatBar({ return } + // Same for a bare `@path` — a hand-typed or Tab-descended path chips into + // the `@file:`/`@folder:` ref it means, instead of submitting as plain text + // the backend never resolves. + if (withUndoPoint(() => chipTypedPathOnSpace(event))) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + // Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is // reserved for the global command palette. if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') { diff --git a/apps/desktop/src/app/chat/composer/path-refs.test.ts b/apps/desktop/src/app/chat/composer/path-refs.test.ts new file mode 100644 index 000000000000..da7cf1dc6f15 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.test.ts @@ -0,0 +1,103 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' + +/** + * Tab-descending into a folder from the `@` popover leaves a bare + * `@apps/desktop/` in the editor. That is not a reference — the backend's + * REFERENCE_PATTERN only matches `@kind:value` — so it used to submit as plain + * text and silently attach nothing. + */ + +/** An editor holding `text` with a collapsed caret at its end, plus the space + * keydown the composer would hand `chipTypedPathOnSpace`. */ +const spaceOn = (text: string) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, text.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('pathifyRefs', () => { + it('promotes a Tab-descended folder token', () => { + expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`') + }) + + it('promotes a typed file path', () => { + expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please') + }) + + it('promotes every bare path in one message', () => { + expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`') + }) + + it('leaves an already-typed directive alone', () => { + const text = 'look at @folder:`apps/desktop` ok' + + expect(pathifyRefs(text)).toBe(text) + }) + + it('leaves a handle alone', () => { + expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this') + }) + + it('leaves simple refs alone', () => { + expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged') + }) + + it('leaves an email alone', () => { + expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x') + }) + + it('is a no-op without an @', () => { + expect(pathifyRefs('nothing here')).toBe('nothing here') + }) +}) + +describe('chipTypedPathOnSpace', () => { + it('chips a Tab-descended folder token into a real @folder: chip', () => { + const { editor, event } = spaceOn('look at @apps/desktop/') + + expect(chipTypedPathOnSpace(event)).toBe(true) + + const chip = editor.querySelector('[data-ref-text]') + + expect(chip?.getAttribute('data-ref-kind')).toBe('folder') + expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop') + expect(chip?.textContent).toContain('desktop') + expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ') + }) + + it('chips a typed file path', () => { + const { editor, event } = spaceOn('read @src/main.ts') + + expect(chipTypedPathOnSpace(event)).toBe(true) + expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull() + expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ') + }) + + it('leaves a handle alone', () => { + const { editor, event } = spaceOn('cc @teknium1') + + expect(chipTypedPathOnSpace(event)).toBe(false) + expect(editor.querySelector('[data-ref-text]')).toBeNull() + }) + + it('leaves an already-typed ref alone', () => { + const { event } = spaceOn('look at @folder:`apps/desktop`') + + expect(chipTypedPathOnSpace(event)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/path-refs.ts b/apps/desktop/src/app/chat/composer/path-refs.ts new file mode 100644 index 000000000000..c326727afa21 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-path recognition for the composer. Walking into a folder from the `@` + * popover (Tab) re-types the token as a bare `@apps/desktop/` so the next + * `complete.path` lists that folder's children. That's right while typing, but + * a bare token is not a reference: `REFERENCE_PATTERN` only matches + * `@kind:value`, so a draft sent with one attaches nothing and renders as + * plain text instead of a chip. + * + * So the bare token is promoted to its typed form on the way out — the same + * shape `url-refs.ts` uses for bare links. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1` +// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns +// someone's name into a file reference. A separator is the cheap signal that the +// user meant a path. The token also can't start with a `:` kind prefix — that is +// already a typed ref and REF_RE owns it. +const BARE_PATH_RE = /(? { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(BARE_PATH_RE)) { + const start = match.index ?? 0 + const ref = barePathRef(match[1] || '') + + if (!ref || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}${ref}` + cursor = start + match[0].length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed + * path chips the same way a picked one does. Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedPathOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('@')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_BARE_PATH_RE.exec(before) : null + const token = match?.[0] + const ref = match?.[1] ? barePathRef(match[1]) : null + + if (!token || !ref) { + return false + } + + const directive = ref.match(/^@([^:]+):(.+)$/) + + if (!directive) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 12ff50eb2227..52411d6bd3e9 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -30,6 +30,7 @@ import { type InlineRefInput, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs' +import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs' import { composerPlainText, insertComposerContentsAtCaret, @@ -543,7 +544,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess recordUndoPoint() // Links land as `@url:` chips, same as the main composer. - insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText)) + insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText))) syncDraftFromEditor(event.currentTarget) } @@ -685,6 +686,15 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess return } + // Same for a bare `@path`. + if (withUndoPoint(() => chipTypedPathOnSpace(event))) { + event.preventDefault() + rememberInitialDraft() + syncDraftFromEditor(event.currentTarget) + + return + } + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submitEdit(event.currentTarget)