Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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"
>
<ul
#listbox
[id]="listboxId()"
role="listbox"
aria-label="Agents you can mention"
Expand Down Expand Up @@ -102,6 +112,27 @@ export class AgentMentionMenuComponent {
readonly picked = output<MentionableAgent>();
readonly browseAll = output<void>();

private readonly listbox = viewChild<ElementRef<HTMLUListElement>>('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<HTMLElement>('[role="option"]')[index];
option?.scrollIntoView?.({ block: 'nearest' });
});

readonly listboxId = () => 'agent-mention-listbox';

optionId(index: number): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MentionableAgent[]>(AGENTS);
readonly loading = signal(false);
async load(): Promise<void> {}
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<ChatInputComponent>;
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 ');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {}
}

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);
});
});
Loading