diff --git a/frontend/ai.client/src/app/session/components/chat-input/agent-mention-menu.component.ts b/frontend/ai.client/src/app/session/components/chat-input/agent-mention-menu.component.ts
index fe14e896..ded1a94d 100644
--- a/frontend/ai.client/src/app/session/components/chat-input/agent-mention-menu.component.ts
+++ b/frontend/ai.client/src/app/session/components/chat-input/agent-mention-menu.component.ts
@@ -1,4 +1,13 @@
-import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ computed,
+ effect,
+ input,
+ output,
+ viewChild,
+} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { heroArrowRight } from '@ng-icons/heroicons/outline';
import { AgentIconComponent } from '../../../agents/components/agent-icon.component';
@@ -31,6 +40,7 @@ import { MentionableAgent } from '../../../agents/services/agent-mention.service
class="absolute bottom-full left-0 z-20 mb-2 w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
>
();
readonly browseAll = output();
+ private readonly listbox = viewChild>('listbox');
+
+ /**
+ * Keep the highlighted row visible.
+ *
+ * The list scrolls at eight rows (`max-h-72`), and the parent owns the keyboard, so
+ * nothing else would bring a row below the fold into view — arrowing down would move a
+ * highlight the user cannot see.
+ */
+ private readonly scrollActiveIntoView = effect(() => {
+ const index = this.activeIndex();
+ const list = this.listbox()?.nativeElement;
+ if (!list) {
+ return;
+ }
+ // Positional lookup rather than by id: group headings are `role="presentation"`, so
+ // the option elements line up one-to-one with the flat index the parent counts in.
+ const option = list.querySelectorAll('[role="option"]')[index];
+ option?.scrollIntoView?.({ block: 'nearest' });
+ });
+
readonly listboxId = () => 'agent-mention-listbox';
optionId(index: number): string {
diff --git a/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts
new file mode 100644
index 00000000..7556472b
--- /dev/null
+++ b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts
@@ -0,0 +1,142 @@
+import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { AgentMentionService, MentionableAgent } from '../../../agents/services/agent-mention.service';
+import { FileUploadService } from '../../../services/file-upload';
+import { SystemPromptsService } from '../../../services/system-prompts/system-prompts.service';
+import { ToastService } from '../../../services/toast/toast.service';
+import { ToolService } from '../../../services/tool/tool.service';
+import { VoiceChatService } from '../../services/voice';
+import { ChatInputComponent } from './chat-input.component';
+
+const AGENTS: MentionableAgent[] = [
+ { agentId: 'a1', name: 'Alpha', group: 'own' },
+ { agentId: 'a2', name: 'Bravo', group: 'own' },
+ { agentId: 'a3', name: 'Charlie', group: 'pinned' },
+];
+
+class MentionServiceStub {
+ readonly mentionable = signal(AGENTS);
+ readonly loading = signal(false);
+ async load(): Promise {}
+ search(query: string): MentionableAgent[] {
+ const needle = query.trim().toLowerCase();
+ return this.mentionable().filter((agent) => agent.name.toLowerCase().startsWith(needle));
+ }
+}
+
+describe('ChatInputComponent — the `@` menu keyboard path (D11)', () => {
+ let fixture: ComponentFixture;
+ let component: ChatInputComponent;
+ let textarea: HTMLTextAreaElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChatInputComponent],
+ providers: [
+ { provide: AgentMentionService, useClass: MentionServiceStub },
+ {
+ provide: FileUploadService,
+ useValue: {
+ pendingUploadsList: signal([]),
+ hasActivePendingUploads: signal(false),
+ readyUploadIds: signal([]),
+ clearReadyUploads: () => undefined,
+ clearPendingUpload: () => undefined,
+ },
+ },
+ { provide: ToastService, useValue: { error: () => undefined, warning: () => undefined, info: () => undefined } },
+ { provide: ToolService, useValue: {} },
+ {
+ provide: VoiceChatService,
+ useValue: {
+ status: signal('idle'),
+ isVoiceActive: signal(false),
+ agentTranscript: signal(''),
+ },
+ },
+ { provide: SystemPromptsService, useValue: { activePrompt: signal(null) } },
+ { provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
+ ],
+ })
+ // The composer's child components (model dropdown, quota banners, file cards) drag in
+ // their own service graphs and have nothing to do with the keyboard path under test.
+ .overrideComponent(ChatInputComponent, {
+ set: { imports: [], schemas: [NO_ERRORS_SCHEMA] },
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(ChatInputComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('showFileControls', false);
+ fixture.componentRef.setInput('showVoiceControl', false);
+ fixture.componentRef.setInput('showSettingsControl', false);
+ fixture.componentRef.setInput('autoFocus', false);
+ fixture.detectChanges();
+
+ textarea = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement;
+ });
+
+ /** Type into the textarea the way the DOM does: value first, then the input event. */
+ function type(value: string): void {
+ textarea.value = value;
+ textarea.setSelectionRange(value.length, value.length);
+ textarea.dispatchEvent(new Event('input'));
+ fixture.detectChanges();
+ }
+
+ /**
+ * A key press as the browser delivers it — `keydown` *and* `keyup`. The keyup half is
+ * the whole point: it is bound to the caret-move handler, and an unconditional token
+ * resync there used to snap the highlight back to the first row.
+ */
+ function pressKey(key: string): void {
+ textarea.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true, bubbles: true }));
+ textarea.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
+ fixture.detectChanges();
+ }
+
+ it('opens the menu on a word-initial `@`', () => {
+ type('@');
+ expect(component.isMentionMenuOpen()).toBe(true);
+ });
+
+ it('walks the list with ArrowDown and stays where it lands (regression: keyup reset)', () => {
+ type('@');
+
+ pressKey('ArrowDown');
+ expect(component.mentionActiveIndex()).toBe(1);
+
+ pressKey('ArrowDown');
+ expect(component.mentionActiveIndex()).toBe(2);
+ });
+
+ it('wraps with ArrowUp from the first row', () => {
+ type('@');
+ pressKey('ArrowUp');
+ expect(component.mentionActiveIndex()).toBe(AGENTS.length - 1);
+ });
+
+ it('resets the highlight when the query itself changes', () => {
+ type('@');
+ pressKey('ArrowDown');
+ expect(component.mentionActiveIndex()).toBe(1);
+
+ type('@B');
+ expect(component.mentionActiveIndex()).toBe(0);
+ });
+
+ it('commits the highlighted agent on Enter rather than sending the message', () => {
+ let submitted = false;
+ component.messageSubmitted.subscribe(() => (submitted = true));
+
+ type('@');
+ pressKey('ArrowDown');
+ pressKey('Enter');
+
+ expect(submitted).toBe(false);
+ expect(component.mentionedAgent()?.name).toBe('Bravo');
+ expect(component.userInput()).toBe('@Bravo ');
+ });
+});
diff --git a/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.ts b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.ts
index c967afed..246d8d0b 100644
--- a/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.ts
+++ b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.ts
@@ -407,8 +407,17 @@ export class ChatInputComponent {
}
void this.mentionService.load();
- this.mentionToken.set({ query: match[1], start: caret - match[1].length - 1 });
- this.mentionActiveIndex.set(0);
+ const next: MentionToken = { query: match[1], start: caret - match[1].length - 1 };
+
+ // Reset the highlight only when the token itself changed. This runs on `keyup` too,
+ // and arrow keys are `preventDefault`ed in `onKeyDown` — so an unconditional reset
+ // here would drag the selection back to the first row on the keyup of every
+ // ArrowDown, making the menu impossible to walk.
+ const current = this.mentionToken();
+ if (!current || current.query !== next.query || current.start !== next.start) {
+ this.mentionActiveIndex.set(0);
+ }
+ this.mentionToken.set(next);
}
/** Caret moves that are not edits — a click or an arrow key — also open or close the menu. */
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.spec.ts
new file mode 100644
index 00000000..8865d127
--- /dev/null
+++ b/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.spec.ts
@@ -0,0 +1,105 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { afterEach, describe, expect, it } from 'vitest';
+import { AgentMentionService } from '../../../../agents/services/agent-mention.service';
+import { MentionTextComponent, splitMentions } from './mention-text.component';
+
+const NAMES = ['Brand Deck Builder', 'Brand', 'myBoiseState'];
+
+describe('splitMentions', () => {
+ it('returns the whole string when there is nothing to match', () => {
+ expect(splitMentions('plain text', NAMES)).toEqual([{ text: 'plain text', isMention: false }]);
+ });
+
+ it('splits a multi-word agent name out of the surrounding prose', () => {
+ expect(splitMentions('hey @Brand Deck Builder can you help', NAMES)).toEqual([
+ { text: 'hey ', isMention: false },
+ { text: '@Brand Deck Builder', isMention: true },
+ { text: ' can you help', isMention: false },
+ ]);
+ });
+
+ it('prefers the longest name so a prefix cannot swallow the match', () => {
+ const segments = splitMentions('@Brand Deck Builder', NAMES);
+ expect(segments).toEqual([{ text: '@Brand Deck Builder', isMention: true }]);
+ });
+
+ it('matches a mention at the start of the message', () => {
+ expect(splitMentions('@myBoiseState what is my balance', NAMES)).toEqual([
+ { text: '@myBoiseState', isMention: true },
+ { text: ' what is my balance', isMention: false },
+ ]);
+ });
+
+ it('matches case-insensitively', () => {
+ expect(splitMentions('@mybOISEsTATE hi', NAMES)[0]).toEqual({
+ text: '@mybOISEsTATE',
+ isMention: true,
+ });
+ });
+
+ it('leaves an email address alone — a mention has to start a word', () => {
+ expect(splitMentions('mail me at phil@Brand.edu', NAMES)).toEqual([
+ { text: 'mail me at phil@Brand.edu', isMention: false },
+ ]);
+ });
+
+ it('does not match a name that only prefixes a longer word', () => {
+ expect(splitMentions('@Branding is fun', NAMES)).toEqual([
+ { text: '@Branding is fun', isMention: false },
+ ]);
+ });
+
+ it('preserves newlines around a mention', () => {
+ expect(splitMentions('line one\n@Brand\nline two', NAMES)).toEqual([
+ { text: 'line one\n', isMention: false },
+ { text: '@Brand', isMention: true },
+ { text: '\nline two', isMention: false },
+ ]);
+ });
+
+ it('handles regex metacharacters in an agent name', () => {
+ expect(splitMentions('ask @C++ Helper about it', ['C++ Helper'])).toEqual([
+ { text: 'ask ', isMention: false },
+ { text: '@C++ Helper', isMention: true },
+ { text: ' about it', isMention: false },
+ ]);
+ });
+
+ it('renders plain when no names have loaded yet', () => {
+ expect(splitMentions('@Brand hello', [])).toEqual([{ text: '@Brand hello', isMention: false }]);
+ });
+});
+
+describe('MentionTextComponent', () => {
+ class MentionServiceStub {
+ readonly mentionable = signal([{ agentId: 'a1', name: 'Brand Deck Builder', group: 'own' }]);
+ async load(): Promise {}
+ }
+
+ async function render(text: string) {
+ await TestBed.configureTestingModule({
+ imports: [MentionTextComponent],
+ providers: [{ provide: AgentMentionService, useClass: MentionServiceStub }],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(MentionTextComponent);
+ fixture.componentRef.setInput('text', text);
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ afterEach(() => TestBed.resetTestingModule());
+
+ it('bolds the mention and leaves the rest of the text alone', async () => {
+ const fixture = await render('hey @Brand Deck Builder look at line 2');
+ const bold = fixture.nativeElement.querySelector('.font-semibold') as HTMLElement;
+ expect(bold.textContent).toBe('@Brand Deck Builder');
+ });
+
+ it('reproduces the message text exactly — no stray whitespace around the runs', async () => {
+ const source = 'hey @Brand Deck Builder\n indented line';
+ const fixture = await render(source);
+ expect(fixture.nativeElement.textContent).toBe(source);
+ });
+});
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.ts
new file mode 100644
index 00000000..40e18ab9
--- /dev/null
+++ b/frontend/ai.client/src/app/session/components/message-list/components/mention-text.component.ts
@@ -0,0 +1,106 @@
+import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
+import { AgentMentionService } from '../../../../agents/services/agent-mention.service';
+
+/** One run of message text, flagged as an Agent `@`-mention or as plain prose. */
+export interface MentionSegment {
+ text: string;
+ isMention: boolean;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Split `text` into plain and `@Agent Name` runs.
+ *
+ * Matching is driven by the **known names** rather than by a `@\w+` pattern, because Agent
+ * names contain spaces ("Brand Deck Builder") and because `@here`, an email address or a
+ * npm scope in a code question must stay plain text. Longest name first, so an Agent whose
+ * name prefixes another's cannot swallow the match.
+ *
+ * A mention must start a word and end at a word boundary — `foo@Agent` is an address, not a
+ * mention.
+ */
+export function splitMentions(text: string, names: readonly string[]): MentionSegment[] {
+ const candidates = names.filter((name) => name.trim().length > 0);
+ if (candidates.length === 0 || !text.includes('@')) {
+ return [{ text, isMention: false }];
+ }
+
+ const alternation = [...candidates]
+ .sort((a, b) => b.length - a.length)
+ .map(escapeRegExp)
+ .join('|');
+ const pattern = new RegExp(`(^|\\s)(@(?:${alternation}))(?![\\w-])`, 'gi');
+
+ const segments: MentionSegment[] = [];
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = pattern.exec(text)) !== null) {
+ const mentionStart = match.index + match[1].length;
+ if (mentionStart > cursor) {
+ segments.push({ text: text.slice(cursor, mentionStart), isMention: false });
+ }
+ segments.push({ text: match[2], isMention: true });
+ cursor = mentionStart + match[2].length;
+ }
+
+ if (cursor < text.length) {
+ segments.push({ text: text.slice(cursor), isMention: false });
+ }
+
+ return segments.length > 0 ? segments : [{ text, isMention: false }];
+}
+
+/**
+ * Renders user message text with `@`-mentions set apart from what the user typed.
+ *
+ * The literal `@Name` is what the composer left in the message (D11), so the thread reads
+ * back exactly as it was sent; this only changes its weight so a mention is legible as an
+ * address rather than as prose.
+ *
+ * Names come from {@link AgentMentionService}, the same list the composer's `@` menu offers.
+ * It is session-cached and already warmed by the composer, so this costs nothing on the
+ * render path; if it has not loaded yet the text simply renders plain and re-renders bold
+ * when the signal fills in.
+ */
+@Component({
+ selector: 'app-mention-text',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ // Every run is wrapped in a ``, including the plain ones: a bare interpolation
+ // sits in a text node whose surrounding indentation Angular collapses to a single
+ // space, which would inject stray spaces into `whitespace-pre-wrap` message text.
+ template: `@for (segment of segments(); track $index) {
+ @if (segment.isMention) {
+ {{ segment.text }}
+ } @else {
+ {{ segment.text }}
+ }
+ }`,
+ styles: `
+ :host {
+ display: inline;
+ }
+ `,
+})
+export class MentionTextComponent {
+ readonly text = input.required();
+
+ private readonly mentionService = inject(AgentMentionService);
+
+ constructor() {
+ // Warm the candidate list. Reloading straight into a thread renders its messages
+ // before the composer has ever been focused, and without the names every mention
+ // would read back as plain prose. `load()` is idempotent and session-cached.
+ void this.mentionService.load();
+ }
+
+ readonly segments = computed(() =>
+ splitMentions(
+ this.text(),
+ this.mentionService.mentionable().map((agent) => agent.name),
+ ),
+ );
+}
diff --git a/frontend/ai.client/src/app/session/components/message-list/components/user-message.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/user-message.component.ts
index 1b68d19c..eea9a34c 100644
--- a/frontend/ai.client/src/app/session/components/message-list/components/user-message.component.ts
+++ b/frontend/ai.client/src/app/session/components/message-list/components/user-message.component.ts
@@ -11,6 +11,7 @@ import {
} from '@angular/core';
import { ContentBlock, Message, FileAttachmentData } from '../../../services/models/message.model';
import { FileAttachmentBadgeComponent, ImageAttachmentGroupComponent } from './file-attachment';
+import { MentionTextComponent } from './mention-text.component';
import { LocalSettingsService } from '../../../../services/local-settings.service';
import { parseIso } from '../../../../utils/date';
@@ -23,7 +24,7 @@ const MAX_HEIGHT_PX = 200;
@Component({
selector: 'app-user-message',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [FileAttachmentBadgeComponent, ImageAttachmentGroupComponent],
+ imports: [FileAttachmentBadgeComponent, ImageAttachmentGroupComponent, MentionTextComponent],
template: `
@if (hasTextContent() || hasFileAttachments()) {
@@ -48,12 +49,12 @@ const MAX_HEIGHT_PX = 200;
class="overflow-hidden transition-[max-height] duration-300 ease-in-out"
[style.max-height]="expanded() ? 'none' : maxHeightPx + 'px'"
>
- @if (displayText()) {
-
{{ displayText() }}
+ @if (displayText(); as text) {
+
} @else {
@for (block of message().content; track $index) {
@if (block.type === 'text' && block.text) {
-
{{ block.text }}
+
}
}
}