Clarify AI mention display contract

This commit is contained in:
rcourtman 2026-03-19 17:51:09 +00:00
parent a40a9ae8f6
commit e9768664c8
4 changed files with 42 additions and 26 deletions

View file

@ -4,7 +4,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
export interface MentionResource {
id: string;
name: string;
displayName: string;
type: 'vm' | 'system-container' | 'app-container' | 'agent';
status?: string;
node?: string;
@ -27,7 +27,9 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
const q = props.query.toLowerCase();
if (!q) return props.resources.slice(0, 10); // Show first 10 if no query
return props.resources.filter((r) => r.name.toLowerCase().includes(q)).slice(0, 10); // Limit to 10 results
return props.resources
.filter((r) => r.displayName.toLowerCase().includes(q))
.slice(0, 10); // Limit to 10 results
};
// Reset selection when query changes
@ -153,7 +155,7 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
<span class="text-muted">{getTypeIcon(resource.type)}</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-base-content truncate">{resource.name}</span>
<span class="font-medium text-base-content truncate">{resource.displayName}</span>
<Show when={resource.status}>
<StatusDot
variant={getSimpleStatusIndicator(resource.status).variant}

View file

@ -127,15 +127,15 @@ vi.mock('../MentionAutocomplete', () => ({
MentionAutocomplete: (props: {
visible: boolean;
query: string;
resources: Array<{ id: string; name: string }>;
onSelect: (resource: { id: string; name: string }) => void;
resources: Array<{ id: string; displayName: string }>;
onSelect: (resource: { id: string; displayName: string }) => void;
}) => (
<div
data-testid="mention-autocomplete"
data-visible={String(props.visible)}
data-query={props.query}
data-resource-count={String(props.resources.length)}
data-resource-labels={props.resources.map((resource) => resource.name).join('|')}
data-resource-labels={props.resources.map((resource) => resource.displayName).join('|')}
>
<button
type="button"
@ -528,7 +528,7 @@ describe('AIChat', () => {
{
id: 'agent-1',
name: 'secret-node-1',
displayName: 'secret-node-1',
displayName: 'redacted by policy',
type: 'agent' as const,
status: 'online',
policy: governedPolicy,
@ -537,7 +537,7 @@ describe('AIChat', () => {
{
id: 'agent-2',
name: 'secret-node-2',
displayName: 'secret-node-2',
displayName: 'redacted by policy',
type: 'agent' as const,
status: 'online',
policy: governedPolicy,
@ -571,7 +571,7 @@ describe('AIChat', () => {
{
id: 'agent-1',
name: 'secret-node-1',
displayName: 'secret-node-1',
displayName: 'redacted by policy',
type: 'agent' as const,
status: 'online',
policy: governedPolicy,

View file

@ -8,7 +8,7 @@ afterEach(cleanup);
function makeResource(overrides?: Partial<MentionResource>): MentionResource {
return {
id: 'vm-100',
name: 'web-server',
displayName: 'web-server',
type: 'vm',
status: 'running',
node: 'pve1',
@ -17,25 +17,31 @@ function makeResource(overrides?: Partial<MentionResource>): MentionResource {
}
const defaultResources: MentionResource[] = [
makeResource({ id: 'vm-100', name: 'web-server', type: 'vm', status: 'running', node: 'pve1' }),
makeResource({
id: 'vm-100',
displayName: 'web-server',
type: 'vm',
status: 'running',
node: 'pve1',
}),
makeResource({
id: 'ct-200',
name: 'db-container',
displayName: 'db-container',
type: 'system-container',
status: 'running',
node: 'pve2',
}),
makeResource({ id: 'node-1', name: 'pve1', type: 'agent', status: 'running' }),
makeResource({ id: 'node-1', displayName: 'pve1', type: 'agent', status: 'running' }),
makeResource({
id: 'docker-1',
name: 'nginx-proxy',
displayName: 'nginx-proxy',
type: 'app-container',
status: 'running',
node: 'docker-host',
}),
makeResource({
id: 'host-1',
name: 'bare-metal-01',
displayName: 'bare-metal-01',
type: 'agent',
status: 'stopped',
node: undefined,
@ -79,7 +85,7 @@ describe('MentionAutocomplete', () => {
it('shows all resources (up to 10) when query is empty', () => {
renderAutocomplete({ query: '' });
for (const r of defaultResources) {
expect(screen.getByText(r.name)).toBeInTheDocument();
expect(screen.getByText(r.displayName)).toBeInTheDocument();
}
});
@ -97,7 +103,7 @@ describe('MentionAutocomplete', () => {
it('limits results to 10 items', () => {
const manyResources = Array.from({ length: 15 }, (_, i) =>
makeResource({ id: `vm-${i}`, name: `server-${i}` }),
makeResource({ id: `vm-${i}`, displayName: `server-${i}` }),
);
renderAutocomplete({ resources: manyResources, query: '' });

View file

@ -438,7 +438,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const vmid = parseLegacyVmid(vm, platformData);
mentionCandidates.push({
id: `vm:${node}:${vmid}`,
name: getPreferredResourceDisplayName(vm),
displayName: getPreferredResourceDisplayName(vm),
type: 'vm',
status: vm.status === 'running' ? 'running' : 'stopped',
node,
@ -453,7 +453,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const vmid = parseLegacyVmid(container, platformData);
mentionCandidates.push({
id: `system-container:${node}:${vmid}`,
name: getPreferredResourceDisplayName(container),
displayName: getPreferredResourceDisplayName(container),
type: 'system-container',
status: container.status === 'running' ? 'running' : 'stopped',
node,
@ -482,7 +482,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
: runtime.status || 'online';
mentionCandidates.push({
id: `agent:${dockerActionId}`,
name: displayName,
displayName,
type: 'agent',
status: runtimeStatus,
});
@ -494,7 +494,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
: container.id;
mentionCandidates.push({
id: `docker:${dockerActionId}:${originalContainerId}`,
name: getPreferredResourceDisplayName(container),
displayName: getPreferredResourceDisplayName(container),
type: 'app-container',
status: container.status === 'running' ? 'running' : 'exited',
node: hostnameOrId,
@ -506,7 +506,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
for (const node of nodes) {
mentionCandidates.push({
id: `node:${node.platformId || ''}:${node.name}`,
name: getPreferredResourceDisplayName(node),
displayName: getPreferredResourceDisplayName(node),
type: 'agent',
status: node.status,
});
@ -522,7 +522,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
: agentResource.status;
mentionCandidates.push({
id: `agent:${agentActionId}`,
name,
displayName: name,
type: 'agent',
status: agentStatus,
});
@ -537,7 +537,15 @@ export const AIChat: Component<AIChatProps> = (props) => {
const prompt = input().trim();
if (!prompt) return;
const mentions = accumulatedMentions();
const mentionsForAPI = mentions.length > 0 ? mentions : undefined;
const mentionsForAPI =
mentions.length > 0
? mentions.map((mention) => ({
id: mention.id,
name: mention.displayName,
type: mention.type,
node: mention.node,
}))
: undefined;
// Pass findingId from context on the first message, clear after success
const ctx = aiChatStore.context;
const findingId = ctx.findingId;
@ -589,7 +597,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
// Replace @query with the resource name
const before = currentInput.slice(0, startIndex);
const after = currentInput.slice(cursorPos);
const newValue = `${before}@${resource.name} ${after}`;
const newValue = `${before}@${resource.displayName} ${after}`;
setInput(newValue);
setMentionActive(false);
@ -605,7 +613,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
setTimeout(() => {
if (textareaRef) {
textareaRef.focus();
const newCursorPos = startIndex + resource.name.length + 2; // +2 for @ and space
const newCursorPos = startIndex + resource.displayName.length + 2; // +2 for @ and space
textareaRef.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);