From b5385ff40f3fcbbd3bf843d4830b3220d5086ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 26 Aug 2026 06:39:27 +0000 Subject: [PATCH] @ in the composer, to reference another chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit type @, a list appears, keep typing to narrow, arrows to move, enter or tab to pick, escape to dismiss. the shape everybody already knows. it inserts `@Todo Guy [6305e570]` rather than just a name. the composer is a textarea — discord renders a pill because it uses a contenteditable, and matching that means replacing the composer — so a mention here is text, and the text has to carry enough to ACT on. 28 sessions on this machine, several with near-identical opening lines: "the todo one" is not a resolvable instruction. COMMANDERS COME FROM COMMAND HQ, not the chat list, and that is the whole reason this is not three lines. a commander's real name lives in its row; its transcript title is the setup preamble it was created with. a picker reading only /chat/sessions would show "You have been named "Platform Deployer", and that is th…" for exactly the sessions most worth referencing. the empty-query list is commanders then recent chats. it would be better to offer only sessions somebody NAMED — the server knows, as hasExplicitTitle — but that field is not in the client payload, and a heuristic for "does this look like a name" would be wrong more often than recency is. said so in the file rather than pretending. two details that are bugs if missed: the token follows the CARET, not the end of the value, so editing mid-sentence still opens the menu; and the query survives a space, because the names people give things have spaces in them and a picker that closed on the first one would miss every one of them. both tested. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../src/apps/Chat/components/InputArea.tsx | 76 ++++++++- .../src/apps/Chat/components/MentionMenu.tsx | 55 +++++++ .../src/apps/Chat/useMentions.test.ts | 54 +++++++ .../officerdev/src/apps/Chat/useMentions.ts | 149 ++++++++++++++++++ 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/MentionMenu.tsx create mode 100644 src/workspaces/officerdev/src/apps/Chat/useMentions.test.ts create mode 100644 src/workspaces/officerdev/src/apps/Chat/useMentions.ts diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index b0cc86dd..9170a7bb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -1,5 +1,7 @@ import type { DragEvent } from 'react'; import { useCallback, useRef, useState } from 'react'; +import { useMentions, type Mention } from '../useMentions'; +import { MentionMenu } from './MentionMenu'; import { useSearchParams } from 'react-router'; import { Loader2, Send, Square, ListPlus, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -83,6 +85,32 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => { } = manager; const [urlDialogOpen, setUrlDialogOpen] = useState(false); + + const mentions = useMentions(); + + /** + * Put the chosen mention in, and leave the caret after it. + * + * The caret has to be restored explicitly: React re-renders with the new value and the browser drops the + * selection to the end, which is right here only by accident and wrong the moment somebody mentions + * something mid-sentence. `requestAnimationFrame` because the DOM node has not got the new value yet at + * the point this runs. + */ + const pickMention = useCallback( + (mention: Mention) => { + const next = mentions.apply(input, mention); + if (!next) return; + setInput(next.text); + mentions.dismiss(); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(next.caret, next.caret); + }); + }, + [input, mentions, setInput, textareaRef], + ); const [jobDialogOpen, setJobDialogOpen] = useState(false); // The panel's own directory, so a background job needs no path typed into it. const { cwd } = useWorkspace(); @@ -127,6 +155,15 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => { + {mentions.open && ( + + )} +
{ // Native `disabled`, not a click guard: it also takes the field out of the tab order and stops a // paste, which a guard on send would not. disabled={!!disabled} - onChange={(ev) => setInput(ev.target.value)} + onChange={(ev) => { + setInput(ev.target.value); + mentions.sync(ev.target.value, ev.target.selectionStart ?? ev.target.value.length); + }} + // The caret can move without the text changing — arrows, a click, Home. The `@` under it changes + // with it, so the menu has to follow the CARET and not only the value. + onSelect={(ev) => { + const el = ev.target as HTMLTextAreaElement; + mentions.sync(el.value, el.selectionStart ?? el.value.length); + }} + onBlur={mentions.dismiss} onKeyDown={(ev) => { + // The mention menu owns these keys while it is open, and only while it is open — Enter must + // still send and Escape must still do whatever it did before when there is no menu. + if (mentions.open) { + if (ev.key === 'ArrowDown') { + ev.preventDefault(); + mentions.move(1); + return; + } + if (ev.key === 'ArrowUp') { + ev.preventDefault(); + mentions.move(-1); + return; + } + if (ev.key === 'Enter' || ev.key === 'Tab') { + const chosen = mentions.results[mentions.highlighted]; + if (chosen) { + ev.preventDefault(); + pickMention(chosen); + return; + } + } + if (ev.key === 'Escape') { + ev.preventDefault(); + mentions.dismiss(); + return; + } + } // Enter is how anybody actually sends, so the interception has to be here as well as on the // button. Shift-Enter is a newline and stays one. if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') { diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MentionMenu.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MentionMenu.tsx new file mode 100644 index 00000000..31a6f512 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/MentionMenu.tsx @@ -0,0 +1,55 @@ +import { MessageSquare, Radar } from 'lucide-react'; +import type { Mention } from '../useMentions'; + +/** + * The `@` list, above the composer. + * + * Above rather than below, because the composer sits at the bottom of a panel and a list opening downwards + * would be off-screen. Same reason every chat client puts it there. + * + * Mouse and keyboard drive the same highlight: hovering moves it, so a click and an Enter can never pick + * different rows. That is the bug this shape avoids — a list where the mouse highlights one thing and the + * keyboard another, and pressing Enter takes the one you are not looking at. + */ + +type Props = { + results: Mention[]; + highlighted: number; + onHighlight: (index: number) => void; + onPick: (mention: Mention) => void; +}; + +export const MentionMenu = ({ results, highlighted, onHighlight, onPick }: Props) => ( +
+
    + {results.map((m, i) => { + const Icon = m.kind === 'commander' ? Radar : MessageSquare; + return ( +
  • + +
  • + ); + })} +
+
+); diff --git a/src/workspaces/officerdev/src/apps/Chat/useMentions.test.ts b/src/workspaces/officerdev/src/apps/Chat/useMentions.test.ts new file mode 100644 index 00000000..648739fd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useMentions.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test'; +import { findMentionToken } from './useMentions'; + +/** + * When `@` means a mention and when it does not. + * + * This is the part that misbehaves invisibly: a menu that opens over an email address is merely annoying, + * but one that closes on the first space is useless for exactly the names people give things — "Todo Guy", + * "Platform Deployer". Both directions are pinned here. + */ + +describe('findMentionToken', () => { + test('at the start of the text', () => { + expect(findMentionToken('@', 1)).toEqual({ query: '', start: 0, end: 1 }); + expect(findMentionToken('@todo', 5)).toEqual({ query: 'todo', start: 0, end: 5 }); + }); + + test('after a space, mid-sentence', () => { + const text = 'ask @todo'; + expect(findMentionToken(text, text.length)).toEqual({ query: 'todo', start: 4, end: 9 }); + }); + + // The case that matters most here: chat names have spaces in them. + test('keeps matching across a space, because names have spaces', () => { + const text = 'ask @Todo Guy'; + expect(findMentionToken(text, text.length)).toEqual({ query: 'Todo Guy', start: 4, end: 13 }); + }); + + test('an email address is not a mention', () => { + const text = 'mail me at pastilhas@officer.dev'; + expect(findMentionToken(text, text.length)).toBeNull(); + }); + + test('a newline ends it', () => { + const text = '@todo\nsomething else'; + expect(findMentionToken(text, text.length)).toBeNull(); + }); + + test('gives up on ordinary prose that happens to follow an @', () => { + const long = `@${'x'.repeat(41)}`; + expect(findMentionToken(long, long.length)).toBeNull(); + }); + + // The caret is what decides, not the end of the string — somebody editing earlier in a line still gets + // the menu for the `@` they are inside. + test('reads the token at the caret, not at the end', () => { + const text = 'ask @todo about the thing'; + expect(findMentionToken(text, 9)).toEqual({ query: 'todo', start: 4, end: 9 }); + }); + + test('no @ at all', () => { + expect(findMentionToken('nothing here', 12)).toBeNull(); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/useMentions.ts b/src/workspaces/officerdev/src/apps/Chat/useMentions.ts new file mode 100644 index 00000000..5f809590 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useMentions.ts @@ -0,0 +1,149 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useClaudeSessions } from 'state/useClaudeSessions'; +import { useCommandHqCommanders } from '../CommandHQ/useCommandHq'; + +/** + * `@` in the composer, the way it works everywhere else: type it, a list appears, keep typing to narrow, + * arrows to move, Enter or Tab to pick, Escape to dismiss. + * + * ── What a mention has to survive ── + * + * The composer is a `