@ in the composer, to reference another chat

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 06:39:27 +00:00
co-authored by Claude Opus 5
parent 0a4d7f8ad9
commit b5385ff40f
4 changed files with 333 additions and 1 deletions
@@ -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) => {
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
{mentions.open && (
<MentionMenu
results={mentions.results}
highlighted={mentions.highlighted}
onHighlight={mentions.setHighlighted}
onPick={pickMention}
/>
)}
<div className="flex items-end gap-1 md:gap-2">
<AttachButton
onAttachImage={supportsImages ? attachImage : undefined}
@@ -141,8 +178,45 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
// 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') {
@@ -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) => (
<div className="absolute bottom-full left-0 z-[700] mb-1 w-full max-w-md overflow-hidden rounded-lg border bg-popover shadow-lg">
<ul className="max-h-64 overflow-auto py-1">
{results.map((m, i) => {
const Icon = m.kind === 'commander' ? Radar : MessageSquare;
return (
<li key={m.sessionId}>
<button
type="button"
// `onMouseDown` with preventDefault, not `onClick`: a click would blur the textarea first,
// and the insert has to happen while the caret is still where the `@` is.
onMouseDown={(ev) => {
ev.preventDefault();
onPick(m);
}}
onMouseEnter={() => onHighlight(i)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
i === highlighted ? 'bg-muted' : ''
}`}
>
<Icon
className={`size-3.5 shrink-0 ${m.kind === 'commander' ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="min-w-0 flex-1 truncate">{m.label}</span>
{m.kind === 'commander' && (
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">commander</span>
)}
</button>
</li>
);
})}
</ul>
</div>
);
@@ -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();
});
});
@@ -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 `<textarea>`. Discord renders a pill because it uses a contenteditable, and matching
* that would mean replacing the composer — so a mention here is plain text, and the text has to carry
* enough for the agent to ACT on rather than just read a name.
*
* Hence `@Todo Guy [6305e570]`. The name is what the human reads back; the short id is what makes it a
* reference. Ambiguity is the reason: 28 sessions on this machine, several with near-identical opening
* lines, and "the todo one" is not a resolvable instruction.
*
* ── Which sessions are worth offering ──
*
* Not all of them. Most transcripts are titled by their own first message — `Read /home/…/prompts/deploy-`
* — which is noise in a picker and useless as a reference. So the list is:
*
* - **commanders first, by their ROW name.** This matters: a commander's real name lives in
* `command_hq_sessions.name`, and its transcript title is the setup preamble it was created with. A
* picker reading only the chat list would show exactly the wrong string for the sessions most worth
* referencing — "You have been named "Platform Deployer", and that is th…" instead of the name.
* - **then chats, most recently touched first**, which is the order the list already arrives in.
*
* Nothing is hidden: the empty-query list is just the top of it, and typing searches every session. It
* would be better to offer only sessions somebody NAMED — the server knows, as `hasExplicitTitle` — but
* that field is not in the client payload today, and inventing a heuristic for "does this title look like
* a name" would be wrong more often than recency is.
*/
export type Mention = {
sessionId: string;
label: string;
/** `commander` sorts first and is worth showing differently — it is a thing that acts, not a transcript. */
kind: 'commander' | 'chat';
};
/** What `@` matched, and where it sits, so replacing it does not disturb the rest of the line. */
export type MentionToken = { query: string; start: number; end: number };
/**
* The `@…` being typed at the caret, or null.
*
* Only fires when `@` starts a word — `foo@bar` is an email address, not a mention. The query stops at a
* newline but NOT at a space, because chat names have spaces in them ("Todo Guy") and a picker that closed
* on the first one would be useless for exactly the names people give things. It gives up after 40
* characters, which is longer than any name here and short enough that ordinary prose stops matching.
*/
export function findMentionToken(text: string, caret: number): MentionToken | null {
const upto = text.slice(0, caret);
const at = upto.lastIndexOf('@');
if (at === -1) return null;
const before = at === 0 ? '' : upto[at - 1];
if (before && !/\s/.test(before)) return null;
const query = upto.slice(at + 1);
if (query.includes('\n') || query.length > 40) return null;
return { query, start: at, end: caret };
}
const norm = (s: string) => s.toLowerCase().trim();
export function useMentions() {
const { sessions } = useClaudeSessions(null, { all: true });
// `undefined` = every category. Command HQ polls this already, so the picker rides an existing query.
const { commanders } = useCommandHqCommanders(undefined);
const [token, setToken] = useState<MentionToken | null>(null);
const [highlighted, setHighlighted] = useState(0);
/** Commanders by their row name, then anything a person named. Deduped: a commander is also a session. */
const candidates = useMemo<Mention[]>(() => {
const out: Mention[] = [];
const seen = new Set<string>();
// `CommandHqCommander.id` IS the transcript id — the type is a session summary with Command HQ's own
// fields on top, so there is no separate `sessionId` to read.
for (const c of commanders ?? []) {
if (!c.id || seen.has(c.id)) continue;
seen.add(c.id);
out.push({ sessionId: c.id, label: c.title || `commander ${c.dirName ?? '?'}`, kind: 'commander' });
}
for (const s of sessions ?? []) {
if (seen.has(s.id)) continue;
seen.add(s.id);
out.push({ sessionId: s.id, label: s.title || '(untitled)', kind: 'chat' });
}
return out;
}, [sessions, commanders]);
const results = useMemo<Mention[]>(() => {
if (!token) return [];
const q = norm(token.query);
return (
candidates
.filter((c) => norm(c.label).includes(q))
// Commanders first, then by where the match falls — a name that STARTS with what you typed is
// almost always the one meant.
.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'commander' ? -1 : 1;
const ai = norm(a.label).indexOf(q);
const bi = norm(b.label).indexOf(q);
return ai !== bi ? ai - bi : a.label.localeCompare(b.label);
})
.slice(0, 8)
);
}, [candidates, token]);
const open = token !== null && results.length > 0;
/** Recompute on every keystroke and caret move; clamp the highlight so it never points past the list. */
const sync = useCallback((text: string, caret: number) => {
setToken(findMentionToken(text, caret));
setHighlighted(0);
}, []);
const dismiss = useCallback(() => setToken(null), []);
const move = useCallback(
(delta: number) =>
setHighlighted((i) => (results.length === 0 ? 0 : (i + delta + results.length) % results.length)),
[results.length],
);
/**
* Replace the `@…` with the finished mention.
*
* A trailing space is added because the next thing typed is always more sentence, and Discord does the
* same — without it the caret sits welded to the bracket and you type into the reference.
*/
const apply = useCallback(
(text: string, mention: Mention): { text: string; caret: number } | null => {
if (!token) return null;
const inserted = `@${mention.label} [${mention.sessionId.slice(0, 8)}] `;
const next = text.slice(0, token.start) + inserted + text.slice(token.end);
return { text: next, caret: token.start + inserted.length };
},
[token],
);
return { open, results, highlighted, setHighlighted, sync, dismiss, move, apply };
}