fix(ai-assistant): a11y, scroll, and routing polish

Address a punch list of UX/a11y bugs surfaced while auditing the AI
Assistant:

- Stop auto-scroll fighting the user during streaming. VirtualizedMessages
  now tracks atBottom via Virtuoso's atBottomStateChange and bails the
  manual scrollTo when the user has scrolled away.
- Resolve dynamic-route templates in getRouteKey so /ai-assistant/:id
  picks up the new AI_ASSISTANT title entry instead of falling through to
  the default browser title.
- Give the icon-only message actions (copy / thumbs up-down / regenerate)
  real aria-labels, and aria-pressed reflecting vote state.
- Convert context-picker rows from divs to buttons. Categories become a
  proper tablist with roving tabindex and ArrowUp/ArrowDown navigation;
  entities use aria-pressed for toggle semantics. SCSS resets native
  button defaults and adds focus-visible outlines.
- Enforce required clarification fields. Submit is disabled until every
  required field is filled, and handleSubmit bails early to guard the
  keyboard-Enter bypass.
This commit is contained in:
makeavish
2026-05-20 00:29:03 +05:30
parent 1a2e8ba3b9
commit 1bad9ec760
9 changed files with 132 additions and 24 deletions

View File

@@ -54,5 +54,6 @@
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant"
}

View File

@@ -77,5 +77,6 @@
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server"
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant"
}

View File

@@ -251,13 +251,18 @@
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 8px;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
font-weight: 550;
text-align: left;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
appearance: none;
cursor: pointer;
transition:
background 0.15s ease,
@@ -270,6 +275,11 @@
border-color: var(--l2-border);
}
&:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 1px;
}
&.active {
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
color: var(--l1-foreground);
@@ -316,14 +326,18 @@
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
background: transparent;
color: var(--l1-foreground);
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: left;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
appearance: none;
cursor: pointer;
// Required for the inner span's `text-overflow: ellipsis` to engage —
// flex items default to `min-width: auto` (intrinsic width) and would
@@ -341,6 +355,11 @@
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 1px;
}
&.selected {
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);

View File

@@ -900,15 +900,23 @@ export default function ChatInput({
sideOffset={8}
>
<div className={styles.contextPopoverContent}>
<div className={styles.contextPopoverCategories}>
<div
className={styles.contextPopoverCategories}
role="tablist"
aria-orientation="vertical"
aria-label="Context categories"
>
{CONTEXT_CATEGORIES.map((category) => {
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
const isActive = activeContextCategory === category;
return (
<div
<button
key={category}
type="button"
role="tab"
tabIndex={0}
// Roving tabindex: only the active tab participates in
// the Tab sequence; arrow keys move between tabs.
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
className={cx(styles.contextPopoverCategoryItem, {
[styles.active]: isActive,
@@ -918,16 +926,23 @@ export default function ChatInput({
setPickerSearchQuery('');
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActiveContextCategory(category);
const idx = CONTEXT_CATEGORIES.indexOf(category);
const nextIdx =
e.key === 'ArrowDown'
? (idx + 1) % CONTEXT_CATEGORIES.length
: (idx - 1 + CONTEXT_CATEGORIES.length) %
CONTEXT_CATEGORIES.length;
const next = CONTEXT_CATEGORIES[nextIdx];
setActiveContextCategory(next);
setPickerSearchQuery('');
}
}}
>
<CategoryIcon size={13} />
<span>{category}</span>
</div>
</button>
);
})}
</div>
@@ -970,8 +985,10 @@ export default function ChatInput({
);
return (
<div
<button
key={option.id}
type="button"
aria-pressed={isSelected}
className={cx(styles.contextPopoverEntityItem, {
[styles.selected]: isSelected,
})}
@@ -986,7 +1003,7 @@ export default function ChatInput({
<span className={styles.contextPopoverEntityItemText}>
{option.value}
</span>
</div>
</button>
);
})
)}

View File

@@ -63,7 +63,16 @@ export default function ClarificationForm({
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const isFormValid = fields.every(
(f) => !f.required || isFieldFilled(f, answers[f.id]),
);
const handleSubmit = async (): Promise<void> => {
// Guard against the Submit-button-bypass case (e.g. Enter inside a text
// field): required fields must be filled before we resume the agent.
if (!isFormValid) {
return;
}
setSubmitted(true);
// Approximate queryLength as the JSON encoding of the form answers — the
// clarification API doesn't render a single user-visible string, but the
@@ -136,7 +145,7 @@ export default function ClarificationForm({
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isStreaming}
disabled={isStreaming || !isFormValid}
prefix={<Send />}
>
Submit
@@ -178,6 +187,27 @@ function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
return raw ?? '';
}
// Whether a required field has been answered. Booleans are always considered
// filled (they're initialised to a concrete true/false). For other types we
// reject empty strings, empty arrays, and NaN numbers — including the
// `allowCustom` case where the user picked "Other" but typed nothing, which
// propagates an empty string through `onChange`.
function isFieldFilled(
field: ClarificationFieldEventDTO,
value: unknown,
): boolean {
switch (field.type) {
case ClarificationFieldTypeDTO.multi_select:
return Array.isArray(value) && value.length > 0;
case ClarificationFieldTypeDTO.boolean:
return true;
case ClarificationFieldTypeDTO.number:
return typeof value === 'number' && !Number.isNaN(value);
default:
return typeof value === 'string' && value.trim().length > 0;
}
}
interface FieldInputProps {
field: ClarificationFieldEventDTO;
value: unknown;

View File

@@ -160,6 +160,7 @@ export default function MessageFeedback({
variant="ghost"
onClick={handleCopy}
color="secondary"
aria-label={copied ? 'Copied' : 'Copy message'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
@@ -172,6 +173,8 @@ export default function MessageFeedback({
variant="ghost"
color="secondary"
onClick={(): void => handleVote('positive')}
aria-label="Good response"
aria-pressed={vote === 'positive'}
>
<ThumbsUp size={12} />
</Button>
@@ -186,6 +189,8 @@ export default function MessageFeedback({
variant="ghost"
color="secondary"
onClick={(): void => handleVote('negative')}
aria-label="Bad response"
aria-pressed={vote === 'negative'}
>
<ThumbsDown size={12} />
</Button>
@@ -199,6 +204,7 @@ export default function MessageFeedback({
variant="ghost"
color="secondary"
onClick={onRegenerate}
aria-label="Regenerate response"
>
<RefreshCw size={12} />
</Button>

View File

@@ -47,6 +47,7 @@ export default function UserMessageActions({
variant="ghost"
color="secondary"
onClick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy message'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>

View File

@@ -90,6 +90,11 @@ export default function VirtualizedMessages({
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement | Window | null>(null);
// Tracks whether the scroller is pinned to (or near) the bottom. Updated
// via Virtuoso's `atBottomStateChange` so we can stop force-scrolling the
// user back down when they've intentionally scrolled up to read earlier
// content.
const atBottomRef = useRef(true);
const handleRegenerate = useCallback(
(messageId: string): void => {
@@ -111,8 +116,13 @@ export default function VirtualizedMessages({
// align: 'end')` would only reach the last item's bottom and leave the
// padding hidden below the fold. Use `auto` while streaming so the bottom
// stays glued as text deltas arrive; `smooth` lags when triggered every
// few ms.
// few ms. Bail out if the user has scrolled away from the bottom — that's
// an explicit signal they want to read earlier content without being
// yanked back.
useEffect(() => {
if (!atBottomRef.current) {
return;
}
const scroller = scrollerRef.current;
if (!(scroller instanceof HTMLElement)) {
return;
@@ -132,14 +142,18 @@ export default function VirtualizedMessages({
const followOutput = useCallback(
(atBottom: boolean): false | 'auto' | 'smooth' => {
if (isStreaming) {
return 'auto';
if (!atBottom) {
return false;
}
return atBottom ? 'smooth' : false;
return isStreaming ? 'auto' : 'smooth';
},
[isStreaming],
);
const handleAtBottomStateChange = useCallback((atBottom: boolean): void => {
atBottomRef.current = atBottom;
}, []);
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
@@ -188,6 +202,8 @@ export default function VirtualizedMessages({
className={styles.messages}
totalCount={totalCount}
followOutput={followOutput}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={64}
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
itemContent={(index): JSX.Element => {
if (index < messages.length) {

View File

@@ -1,9 +1,26 @@
import ROUTES from 'constants/routes';
export function getRouteKey(pathname: string): string {
const [routeKey] = Object.entries(ROUTES).find(
([, value]) => value === pathname,
) || ['DEFAULT'];
const PARAM_SEGMENT = /:[^/]+/g;
const REGEX_SPECIALS = /[.+*?^$()[\]{}|\\]/g;
return routeKey;
function templateToRegex(template: string): RegExp {
const pattern = template
.replace(REGEX_SPECIALS, '\\$&')
.replace(PARAM_SEGMENT, '[^/]+');
return new RegExp(`^${pattern}$`);
}
export function getRouteKey(pathname: string): string {
const entries = Object.entries(ROUTES);
const exact = entries.find(([, value]) => value === pathname);
if (exact) {
return exact[0];
}
const dynamic = entries.find(
([, value]) => value.includes(':') && templateToRegex(value).test(pathname),
);
return dynamic?.[0] ?? 'DEFAULT';
}