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) => (
+
+);
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 `