@ 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:
@@ -1,5 +1,7 @@
|
|||||||
import type { DragEvent } from 'react';
|
import type { DragEvent } from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useMentions, type Mention } from '../useMentions';
|
||||||
|
import { MentionMenu } from './MentionMenu';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Loader2, Send, Square, ListPlus, X } from 'lucide-react';
|
import { Loader2, Send, Square, ListPlus, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -83,6 +85,32 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
|||||||
} = manager;
|
} = manager;
|
||||||
|
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
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);
|
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||||
// The panel's own directory, so a background job needs no path typed into it.
|
// The panel's own directory, so a background job needs no path typed into it.
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
@@ -127,6 +155,15 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
|||||||
|
|
||||||
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
|
<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">
|
<div className="flex items-end gap-1 md:gap-2">
|
||||||
<AttachButton
|
<AttachButton
|
||||||
onAttachImage={supportsImages ? attachImage : undefined}
|
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
|
// 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.
|
// paste, which a guard on send would not.
|
||||||
disabled={!!disabled}
|
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) => {
|
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
|
// 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.
|
// button. Shift-Enter is a newline and stays one.
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') {
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user