mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-20 17:00:29 +01:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user