Compare commits

...

2 Commits

7 changed files with 615 additions and 19 deletions

View File

@@ -377,9 +377,63 @@
}
.contextPopoverEmpty {
// Fill the entity panel so the state sits centred in the dead space rather
// than clinging to the top-left corner.
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 20px;
text-align: center;
font-size: 12px;
color: var(--l3-foreground);
padding: 10px 8px;
}
.contextPopoverEmptyIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
// `--empty-accent` is set per category on the root (robin/cherry/forest).
color: var(--empty-accent);
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
border-radius: var(--radius-2);
}
.contextPopoverEmptyTitle {
margin: 0;
max-width: 280px;
color: var(--l2-foreground);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
// popover height. The CTA below is a stock DS link button, so the query is
// kept readable here rather than forcing the button to wrap.
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.contextPopoverEmptyCta {
max-width: 100%;
}
.contextPopoverEmptyCtaLabel {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
text-align: center;
}
.micBtn {

View File

@@ -42,19 +42,22 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
import ContextPickerEmptyState from './ContextPickerEmptyState';
import {
CONTEXT_CATEGORIES,
CONTEXT_CATEGORY_ICONS,
ContextCategory,
} from './contextPicker';
interface ChatInputProps {
onSend: (
@@ -162,10 +165,6 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
@@ -205,12 +204,6 @@ interface ContextEntityItem {
value: string;
}
const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -331,6 +324,30 @@ export default function ChatInput({
[mentionRange, selectedContexts, text],
);
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
// and hand the user the caret at the end so they can finish the sentence.
const handleContextPrefill = useCallback(
(prompt: string) => {
const next = capText(prompt);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
setPickerSearchQuery('');
setIsContextPickerOpen(false);
// Defer so React commits the new value before we place the caret.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) {
return;
}
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
});
},
[capText],
);
const focusCategory = useCallback((category: ContextCategory) => {
setActiveContextCategory(category);
setPickerSearchQuery('');
@@ -824,10 +841,14 @@ export default function ChatInput({
// Type-ahead filter against the `@<query>` typed in the textarea. When
// the picker was opened from the "Add Context" button there's no
// mention query, so fall back to the in-popover search input.
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
const rawMentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end)
: '';
const mentionQuery = rawMentionQuery.toLowerCase();
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
// Original-case query for empty-state copy + prefill ("checkout", not the
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
@@ -1071,9 +1092,11 @@ export default function ChatInput({
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
<ContextPickerEmptyState
category={activeContextCategory}
query={displayQuery}
onPrefill={handleContextPrefill}
/>
) : (
filteredContextOptions.map((option, index) => {
const isSelected = selectedContexts.some(

View File

@@ -0,0 +1,68 @@
import { Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { CSSProperties } from 'react';
import styles from './ChatInput.module.scss';
import {
CONTEXT_CATEGORY_ICONS,
ContextCategory,
getContextPickerEmptyContent,
} from './contextPicker';
// Per-category accent, mapped to semantic accent tokens (robin is the brand
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
// icon and CTA share one colour per category.
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
Dashboards: 'var(--accent-primary)',
Alerts: 'var(--accent-cherry)',
Services: 'var(--accent-forest)',
};
interface ContextPickerEmptyStateProps {
category: ContextCategory;
/** The active search query (mention or in-popover search), original case. */
query: string;
/** Drops the starter prompt into the composer (never auto-sends). */
onPrefill: (prompt: string) => void;
}
/**
* Empty state for the @-mention context picker. Distinguishes a brand-new user
* with nothing to pick (onboarding) from a search that matched nothing, and in
* both cases offers a clickable CTA that seeds the composer.
*/
export default function ContextPickerEmptyState({
category,
query,
onPrefill,
}: ContextPickerEmptyStateProps): JSX.Element {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
category,
query,
);
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
return (
<div
className={styles.contextPopoverEmpty}
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
>
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
<CategoryIcon size={16} />
</span>
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
<Button
type="button"
variant="link"
size="sm"
color="primary"
className={styles.contextPopoverEmptyCta}
onClick={(): void => onPrefill(prefill)}
data-testid={`ai-context-empty-cta-${category}`}
prefix={<Sparkles size={14} />}
>
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
</Button>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
// context providers, so stub them out.
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import { TooltipProvider } from '@signozhq/ui/tooltip';
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
function renderChatInput(): void {
render(
<TooltipProvider>
<ChatInput onSend={jest.fn()} />
</TooltipProvider>,
);
}
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — empty-state CTA prefill flow', () => {
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
renderChatInput();
// Pre-existing prose in the composer.
await userEvent.type(getComposer(), 'show me something');
// Open the picker and search for an entity that does not exist.
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.type(
await screen.findByPlaceholderText(/search dashboards/i),
'chk',
);
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
await userEvent.click(cta);
// Full-replace is intentional: the prefill is a complete sentence, so the
// prior "show me something" prose is discarded rather than producing
// broken grammar (see handleContextPrefill). The query is seeded in.
expect(getComposer().value).toBe('Create a dashboard for chk');
// Picker closed → the empty-state CTA is gone.
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
renderChatInput();
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(getComposer().value).toBe('Create a dashboard for ');
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,135 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { ContextCategory } from '../contextPicker';
import ContextPickerEmptyState from '../ContextPickerEmptyState';
function renderEmptyState(
category: ContextCategory,
query: string,
onPrefill = jest.fn(),
): { onPrefill: jest.Mock; container: HTMLElement } {
const { container } = render(
<ContextPickerEmptyState
category={category}
query={query}
onPrefill={onPrefill}
/>,
);
return { onPrefill, container };
}
function ctaFor(category: ContextCategory): HTMLElement {
return screen.getByTestId(`ai-context-empty-cta-${category}`);
}
describe('ContextPickerEmptyState', () => {
describe('onboarding (no query)', () => {
it('renders dashboards copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Dashboards', '');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
const cta = ctaFor('Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(onPrefill).toHaveBeenCalledTimes(1);
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
});
it('renders alerts copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Alerts', '');
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
});
it('renders instrumentation-flavoured services copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Services', '');
expect(
screen.getByText('No services reporting data yet.'),
).toBeInTheDocument();
expect(ctaFor('Services')).toHaveTextContent(
'Ask me to help set up instrumentation',
);
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for ',
);
});
it('treats a whitespace-only query as onboarding', () => {
renderEmptyState('Dashboards', ' ');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(
screen.getByText('No dashboards match "checkout".'),
).toBeInTheDocument();
expect(ctaFor('Dashboards')).toHaveTextContent(
'Create a dashboard for "checkout"',
);
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
});
it('seeds the query into alerts copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
});
it('uses instrumentation wording for services search misses', async () => {
const { onPrefill } = renderEmptyState('Services', 'checkout');
expect(
screen.getByText('No services match "checkout".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for checkout',
);
});
it('preserves the original casing of the query in copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
expect(
screen.getByText('No dashboards match "Checkout API".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith(
'Create a dashboard for Checkout API',
);
});
});
describe('per-category accent token', () => {
it.each<[ContextCategory, string]>([
['Dashboards', 'var(--accent-primary)'],
['Alerts', 'var(--accent-cherry)'],
['Services', 'var(--accent-forest)'],
])('maps %s to the semantic accent %s', (category, accent) => {
const { container } = renderEmptyState(category, '');
const root = container.firstChild as HTMLElement;
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
});
});
it('does not auto-send: nothing fires until the CTA is clicked', () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(onPrefill).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,100 @@
import {
CONTEXT_CATEGORIES,
getContextPickerEmptyContent,
} from '../contextPicker';
describe('getContextPickerEmptyContent', () => {
describe('onboarding (no query)', () => {
it('returns per-category copy and prefix-only prefill for dashboards', () => {
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('returns per-category copy and prefix-only prefill for alerts', () => {
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
title: 'No alerts yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create an alert for ',
});
});
it('returns instrumentation-flavoured copy for services', () => {
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
title: 'No services reporting data yet.',
ctaLabel: 'Ask me to help set up instrumentation',
prefill: 'Help me set up instrumentation for ',
});
});
it('treats a whitespace-only query as no query', () => {
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('leaves the prefill ending in a space so the caret sits after it', () => {
CONTEXT_CATEGORIES.forEach((category) => {
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
});
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', () => {
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
{
title: 'No dashboards match "checkout".',
ctaLabel: 'Create a dashboard for "checkout"',
prefill: 'Create a dashboard for checkout',
},
);
});
it('seeds the query into alerts copy and prefill', () => {
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
title: 'No alerts match "checkout".',
ctaLabel: 'Create an alert for "checkout"',
prefill: 'Create an alert for checkout',
});
});
it('uses instrumentation wording for services search misses', () => {
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
title: 'No services match "checkout".',
ctaLabel: 'Set up instrumentation for "checkout"',
prefill: 'Help me set up instrumentation for checkout',
});
});
it('preserves the original casing of the query', () => {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
'Dashboards',
'Checkout API',
);
expect(title).toBe('No dashboards match "Checkout API".');
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
expect(prefill).toBe('Create a dashboard for Checkout API');
});
it('trims surrounding whitespace from the query', () => {
expect(
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
).toBe('Create a dashboard for checkout');
});
});
it('never emits an em-dash (house style)', () => {
CONTEXT_CATEGORIES.forEach((category) => {
const empty = getContextPickerEmptyContent(category, '');
const miss = getContextPickerEmptyContent(category, 'q');
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
});
});
});
});

View File

@@ -0,0 +1,103 @@
import { Bell, LayoutDashboard, ShieldCheck } from '@signozhq/icons';
/** Ordered category tabs shown in the @-mention context picker. */
export const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
export type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
/**
* Icon per category, shared by the picker tablist and the empty state. `satisfies`
* keeps the concrete component types so callers can render `<Icon size={n} />`.
*/
export const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
/**
* Resolved copy + composer prefill for one render of the context picker's empty
* state. The picker is tabbed, so a user only ever views one category at a
* time — each category gets its own onboarding and search-miss copy rather than
* a single combined "nothing to show" line.
*/
export interface ContextPickerEmptyContent {
/** Primary line explaining why the list is empty. */
title: string;
/** Clickable call to action that prefills (never auto-sends) the composer. */
ctaLabel: string;
/**
* Text dropped into the composer when the CTA is clicked. When there's no
* search query this is just the prefix with a trailing space, leaving the
* caret at the end for the user to type the entity name.
*/
prefill: string;
}
interface CategoryCopy {
/** Onboarding line, e.g. "No dashboards yet." */
emptyTitle: string;
/** Onboarding CTA label, e.g. "Ask me to create one". */
emptyCtaLabel: string;
/** Search-miss line, e.g. `No dashboards match "checkout".` */
matchTitle: (query: string) => string;
/** Search-miss CTA label, e.g. `Create a dashboard for "checkout"`. */
matchCtaLabel: (query: string) => string;
/**
* Composer prefill prefix (with trailing space). The prefill is always
* `${prefillPrefix}${query}`, so the search-miss case seeds the query and
* the onboarding case leaves only the prefix.
*/
prefillPrefix: string;
}
// Services get instrumentation-flavoured copy: the assistant can't "create" a
// service, they come from telemetry, so the CTA points at setup instead.
const CONTEXT_PICKER_COPY: Record<ContextCategory, CategoryCopy> = {
Dashboards: {
emptyTitle: 'No dashboards yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No dashboards match "${query}".`,
matchCtaLabel: (query) => `Create a dashboard for "${query}"`,
prefillPrefix: 'Create a dashboard for ',
},
Alerts: {
emptyTitle: 'No alerts yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No alerts match "${query}".`,
matchCtaLabel: (query) => `Create an alert for "${query}"`,
prefillPrefix: 'Create an alert for ',
},
Services: {
emptyTitle: 'No services reporting data yet.',
emptyCtaLabel: 'Ask me to help set up instrumentation',
matchTitle: (query) => `No services match "${query}".`,
matchCtaLabel: (query) => `Set up instrumentation for "${query}"`,
prefillPrefix: 'Help me set up instrumentation for ',
},
};
/**
* Build the empty-state copy for a category. The two states are driven solely
* by whether a search query is active: a non-empty query yields the search-miss
* variant, an empty query the onboarding variant.
*/
export function getContextPickerEmptyContent(
category: ContextCategory,
query: string,
): ContextPickerEmptyContent {
const copy = CONTEXT_PICKER_COPY[category];
const trimmed = query.trim();
if (trimmed) {
return {
title: copy.matchTitle(trimmed),
ctaLabel: copy.matchCtaLabel(trimmed),
prefill: `${copy.prefillPrefix}${trimmed}`,
};
}
return {
title: copy.emptyTitle,
ctaLabel: copy.emptyCtaLabel,
prefill: copy.prefillPrefix,
};
}