From 653adf941bed83a838a15b9d2eb944e8af56e772 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 19 Sep 2026 10:02:34 -0700 Subject: [PATCH 1/3] test(web): cover the composer mention parser (CL-8617) --- apps/web/src/chat/mentions.test.ts | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/web/src/chat/mentions.test.ts diff --git a/apps/web/src/chat/mentions.test.ts b/apps/web/src/chat/mentions.test.ts new file mode 100644 index 000000000..16ea3f40a --- /dev/null +++ b/apps/web/src/chat/mentions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { activeMention, applyMention, mentionedAgents } from "./mentions"; + +const roster = [{ name: "Researcher" }, { name: "Writer" }, { name: "Writer Two" }]; + +describe("mentionedAgents", () => { + test("resolves a name to its roster entry", () => { + expect(mentionedAgents("@Writer reply with one word", roster)).toEqual([{ name: "Writer" }]); + }); + + test("ignores a name nothing in the roster answers to", () => { + expect(mentionedAgents("@Nobody are you there", roster)).toEqual([]); + }); + + test("resolves several mentions, case-insensitively", () => { + expect(mentionedAgents("@researcher and @Writer, compare notes", roster)).toEqual([ + { name: "Researcher" }, + { name: "Writer" }, + ]); + }); + + test("prefers the longest matching name", () => { + expect(mentionedAgents("@Writer Two please draft it", roster)).toEqual([ + { name: "Writer Two" }, + ]); + }); + + test("does not treat an address local part as a mention", () => { + expect(mentionedAgents("mail me at carol@writer.example.com", roster)).toEqual([]); + }); +}); + +describe("activeMention / applyMention", () => { + test("reads the mention being typed at the caret", () => { + expect(activeMention("hey @wri", 8)).toEqual({ start: 4, query: "wri" }); + expect(activeMention("hey there", 9)).toBeUndefined(); + }); + + test("replaces the typed fragment with the full token", () => { + const mention = activeMention("hey @wri", 8); + expect(mention).toBeDefined(); + expect(applyMention("hey @wri", mention!, "Writer", 8)).toEqual({ + text: "hey @Writer ", + caret: 12, + }); + }); +}); From 96d61e985d28fb8a8baa8806c572234771306bbc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 19 Sep 2026 10:02:34 -0700 Subject: [PATCH 2/3] feat(web): a workbench message can @mention one agent instead of fanning out (CL-8617) --- apps/web/src/chat/composer.tsx | 93 ++++++++++++++++++++++++++- apps/web/src/chat/mentions.ts | 73 +++++++++++++++++++++ apps/web/src/chat/styles.css | 42 ++++++++++++ apps/web/src/chat/threads-api.ts | 6 +- apps/web/src/pages/workbench-page.tsx | 6 ++ 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/chat/mentions.ts diff --git a/apps/web/src/chat/composer.tsx b/apps/web/src/chat/composer.tsx index 720843fff..b15030484 100644 --- a/apps/web/src/chat/composer.tsx +++ b/apps/web/src/chat/composer.tsx @@ -1,38 +1,127 @@ // No Stop action: neither `threads-api.ts` nor the hub API expose a way to // cancel a running turn yet. +import { useCommandPaletteNavigation } from "@corbits/react-ui"; import { ArrowUp, CircleNotch } from "@/lib/icons"; -import { useState } from "react"; +import { useRef, useState } from "react"; + +import { activeMention, applyMention, matchMentionQuery, type ActiveMention } from "./mentions"; + +export type ComposerMention = { + readonly id: string; + readonly name: string; + /** Shown beside the name in the popover when the roster carries one. */ + readonly detail?: string; +}; export function Composer({ placeholder, busy, disabled, + mentionables = [], onSend, }: { readonly placeholder: string; readonly busy: boolean; readonly disabled?: boolean; + /** Agents an `@` token can address; empty disables the popover. */ + readonly mentionables?: readonly ComposerMention[]; readonly onSend: (text: string) => void; }) { const [text, setText] = useState(""); + const [mention, setMention] = useState(null); + const inputRef = useRef(null); const canSend = text.trim() !== "" && !busy && !disabled; + const matches = mention === null ? [] : matchMentionQuery(mention.query, mentionables); + const open = mention !== null && matches.length > 0; + const send = () => { const trimmed = text.trim(); if (trimmed === "" || busy || disabled) return; setText(""); + setMention(null); onSend(trimmed); }; + + const choose = (id: string) => { + const picked = mentionables.find((candidate) => candidate.id === id); + const input = inputRef.current; + if (picked === undefined || mention === null || input === null) return; + const next = applyMention(text, mention, picked.name, input.selectionStart); + setText(next.text); + setMention(null); + // The caret belongs after the inserted token, which React's re-render + // would otherwise put at the end of the text. + requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange(next.caret, next.caret); + }); + }; + + const navigation = useCommandPaletteNavigation({ + items: matches.map((candidate) => ({ id: candidate.id })), + onSelect: choose, + onClose: () => setMention(null), + }); + + const syncMention = (input: HTMLTextAreaElement) => { + if (mentionables.length === 0) return; + setMention(activeMention(input.value, input.selectionStart) ?? null); + }; + return (
+ {open ? ( +
    + {matches.map((candidate) => ( +
  • + +
  • + ))} +
+ ) : null}