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/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 21df163ef8fc..74f9a7459f65 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) 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 { 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