refactor: support auto-derived contexts and enhance diff display functionality

This commit is contained in:
Yunus M
2026-05-01 15:39:01 +05:30
parent 02a743f8ab
commit 2df265abbf
13 changed files with 1364 additions and 113 deletions

View File

@@ -51,7 +51,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.10",
"@signozhq/ui": "0.0.13",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import ChatInput from './components/ChatInput';
import ChatInput, { autoContextKey } from './components/ChatInput';
import ConversationSkeleton from './components/ConversationSkeleton';
import VirtualizedMessages from './components/VirtualizedMessages';
import { getAutoContexts } from './getAutoContexts';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import { MessageAttachment } from './types';
import { MessageContext } from '../../api/ai/chat';
@@ -20,6 +22,7 @@ export default function ConversationView({
}: ConversationViewProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const location = useLocation();
const conversation = useAIAssistantStore(
(s) => s.conversations[conversationId],
@@ -37,6 +40,42 @@ export default function ConversationView({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
// Auto-derived contexts come from the route the user is currently looking
// at (dashboard detail, service metrics, an explorer, …). Skip when the
// user is on the standalone AI Assistant page — there's no "underlying"
// page context to attach. ChatInput renders these as chips and merges
// them with the user's `@`-mention picks before invoking onSend.
const allAutoContexts = useMemo(
() =>
variant === 'page'
? []
: getAutoContexts(location.pathname, location.search),
[variant, location.pathname, location.search],
);
// User-dismissed auto-context entries. Reset whenever the URL changes —
// dismissals are scoped to "this page", not the whole conversation.
const [dismissedAutoKeys, setDismissedAutoKeys] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setDismissedAutoKeys(new Set());
}, [location.pathname, location.search]);
const autoContexts = useMemo(
() =>
allAutoContexts.filter((ctx) => !dismissedAutoKeys.has(autoContextKey(ctx))),
[allAutoContexts, dismissedAutoKeys],
);
const handleDismissAutoContext = useCallback((key: string): void => {
setDismissedAutoKeys((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
}, []);
const handleSend = useCallback(
(
text: string,
@@ -72,7 +111,12 @@ export default function ConversationView({
<div className={styles.conversation}>
<ConversationSkeleton />
<div className={inputWrapperClass}>
<ChatInput onSend={handleSend} disabled />
<ChatInput
onSend={handleSend}
disabled
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>
</div>
</div>
);
@@ -96,6 +140,8 @@ export default function ConversationView({
onCancel={handleCancel}
disabled={inputDisabled}
isStreaming={isStreamingHere}
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>
</div>
</div>

View File

@@ -1,3 +1,5 @@
@use '../_scrollbar' as *;
$radius: 4px;
.card {
@@ -55,9 +57,131 @@ $radius: 4px;
line-height: 1.5;
}
.diffSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.diffHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.diffHeaderLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.diff {
display: flex;
gap: 8px;
// Fixed-height dialog (70vh) — let the diff fill the body and the
// JSON panes scroll internally rather than pushing the dialog taller.
&.expanded {
flex: 1;
min-height: 0;
.diffBlock {
min-height: 0;
}
.diffJson {
flex: 1;
max-height: none;
overflow: auto;
font-size: 12px;
}
}
// Unified view: a single column instead of two side-by-side blocks.
// The block-level flex switches to column so the diff pane fills.
&.unified {
flex-direction: column;
}
}
.diffHeaderActions {
display: flex;
align-items: center;
gap: 4px;
}
// Container for line-by-line diff output. Mirrors `.diffJson` for scroll
// + monospace styling but renders an inner stack of `.diffLine` rows
// instead of a single `<pre>` so individual lines can be colored.
.diffPane {
font-family: var(--font-mono, monospace);
font-size: 12px;
background: var(--l2-background);
border-radius: $radius;
margin: 0;
overflow: auto;
color: var(--l2-foreground);
flex: 1;
min-height: 0;
@include scrollbar(0.4rem);
&.wrapped .diffLineText {
white-space: pre-wrap;
word-break: break-word;
}
}
.diffLine {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 0 8px;
min-height: 18px;
line-height: 1.5;
}
.diffLineAdd {
background: rgba(34, 197, 94, 0.12);
color: var(--l1-foreground);
.diffGutter {
color: var(--bg-forest-500, #22c55e);
}
}
.diffLineRemove {
background: rgba(239, 68, 68, 0.12);
color: var(--l1-foreground);
.diffGutter {
color: var(--bg-cherry-500, #ef4444);
}
}
// Empty filler row in split view to keep before/after columns aligned
// when one side has an added/removed line. Visible as a faint band so
// the eye still tracks the row.
.diffLinePlaceholder {
background: rgba(120, 120, 120, 0.06);
min-height: 18px;
}
.diffGutter {
flex-shrink: 0;
width: 12px;
text-align: center;
font-weight: 600;
user-select: none;
color: var(--l3-foreground, var(--l2-foreground));
}
.diffLineText {
white-space: pre;
flex: 1;
min-width: 0;
}
.diffBlock {
@@ -75,6 +199,14 @@ $radius: 4px;
}
}
.diffBlockHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.diffLabel {
font-size: 10px;
font-weight: 600;
@@ -89,10 +221,45 @@ $radius: 4px;
border-radius: $radius;
padding: 5px 7px;
margin: 0;
overflow-x: auto;
overflow: auto;
white-space: pre;
max-height: 140px;
color: var(--l2-foreground);
@include scrollbar(0.4rem);
// Wrap long lines instead of horizontal scrolling. Used in the
// expanded modal when the user toggles the "Wrap text" button.
&.wrapped {
white-space: pre-wrap;
word-break: break-word;
overflow-x: hidden;
}
}
.diffModalBody {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.diffToolbarRow {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.diffModalSummary {
font-size: 13px;
color: var(--l2-foreground);
margin: 0;
line-height: 1.5;
flex-shrink: 0;
}
.actions {

View File

@@ -1,8 +1,31 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
import { Button } from '@signozhq/ui';
import type { ApprovalEventDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Check, Shield, X } from '@signozhq/icons';
import {
Button,
Dialog,
DialogCloseButton,
DialogContent,
DialogHeader,
DialogSubtitle,
DialogTitle,
ToggleGroup,
ToggleGroupItem,
} from '@signozhq/ui';
import type {
ApprovalEventDTO,
ApprovalEventDTODiffAnyOf,
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
Check,
Columns2,
Copy,
List,
Maximize2,
Shield,
WrapText,
X,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
@@ -29,6 +52,9 @@ export default function ApprovalCard({
);
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
const [diffExpanded, setDiffExpanded] = useState(false);
const [wrapText, setWrapText] = useState(false);
const [viewMode, setViewMode] = useState<DiffViewMode>('split');
const handleApprove = async (): Promise<void> => {
setDecided('approved');
@@ -72,26 +98,84 @@ export default function ApprovalCard({
<p className={styles.summary}>{approval.summary}</p>
{approval.diff && (
<div className={styles.diff}>
{approval.diff.before !== undefined && (
<div className={cx(styles.diffBlock, styles.before)}>
<span className={styles.diffLabel}>Before</span>
<pre className={styles.diffJson}>
{JSON.stringify(approval.diff.before, null, 2)}
</pre>
</div>
)}
{approval.diff.after !== undefined && (
<div className={cx(styles.diffBlock, styles.after)}>
<span className={styles.diffLabel}>After</span>
<pre className={styles.diffJson}>
{JSON.stringify(approval.diff.after, null, 2)}
</pre>
</div>
)}
<div className={styles.diffSection}>
<div className={styles.diffHeader}>
<span className={styles.diffHeaderLabel}>Diff</span>
<Button
variant="link"
size="sm"
color="secondary"
onClick={(): void => setDiffExpanded(true)}
title="Expand diff"
aria-label="Expand diff"
>
<Maximize2 size={12} />
</Button>
</div>
<DiffView diff={approval.diff} />
</div>
)}
<Dialog open={diffExpanded} onOpenChange={setDiffExpanded}>
<DialogContent
className={styles.diffDialog}
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
>
<DialogHeader>
<DialogTitle>Approval diff</DialogTitle>
<DialogSubtitle>
{approval.actionType} · {approval.resourceType}
</DialogSubtitle>
</DialogHeader>
<div className={styles.diffModalBody}>
<p className={styles.diffModalSummary}>{approval.summary}</p>
<div className={styles.diffToolbarRow}>
<ToggleGroup
type="single"
size="sm"
value={viewMode}
onChange={(next): void => {
// Radix `single` group can emit '' when the active item
// is clicked again — preserve the current mode.
if (next === 'split' || next === 'unified') {
setViewMode(next);
}
}}
>
<ToggleGroupItem value="split" aria-label="Split view">
<Columns2 size={12} />
</ToggleGroupItem>
<ToggleGroupItem value="unified" aria-label="Unified view">
<List size={12} />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="multiple"
size="sm"
value={wrapText ? ['wrap'] : []}
onChange={(next): void => setWrapText(next.includes('wrap'))}
>
<ToggleGroupItem
value="wrap"
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
>
<WrapText size={12} />
</ToggleGroupItem>
</ToggleGroup>
</div>
{approval.diff && (
<DiffView
diff={approval.diff}
expanded
wrapText={wrapText}
viewMode={viewMode}
/>
)}
</div>
<DialogCloseButton onClick={(): void => setDiffExpanded(false)} />
</DialogContent>
</Dialog>
<div className={styles.actions}>
<Button
variant="solid"
@@ -116,3 +200,273 @@ export default function ApprovalCard({
</div>
);
}
type DiffViewMode = 'split' | 'unified';
interface DiffViewProps {
diff: ApprovalEventDTODiffAnyOf;
expanded?: boolean;
/** When true, long lines wrap instead of horizontally scrolling. */
wrapText?: boolean;
/** Side-by-side ('split') vs single-column ('unified'). Only honored when expanded. */
viewMode?: DiffViewMode;
}
function DiffView({
diff,
expanded = false,
wrapText = false,
viewMode = 'split',
}: DiffViewProps): JSX.Element {
const beforeText =
diff.before !== undefined ? JSON.stringify(diff.before, null, 2) : '';
const afterText =
diff.after !== undefined ? JSON.stringify(diff.after, null, 2) : '';
// In the inline (collapsed) preview keep the original two-pane layout
// without diff highlighting — diffing is opt-in via the expanded modal.
if (!expanded) {
const jsonClass = cx(styles.diffJson, { [styles.wrapped]: wrapText });
return (
<div className={styles.diff}>
{diff.before !== undefined && (
<div className={cx(styles.diffBlock, styles.before)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Before</span>
</div>
<pre className={jsonClass}>{beforeText}</pre>
</div>
)}
{diff.after !== undefined && (
<div className={cx(styles.diffBlock, styles.after)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>After</span>
</div>
<pre className={jsonClass}>{afterText}</pre>
</div>
)}
</div>
);
}
const lines = computeLineDiff(beforeText, afterText);
if (viewMode === 'unified') {
// Build the same +/-/space-prefixed text that's on screen so Copy
// gives the user exactly what they see.
const unifiedText = lines
.map((line) => `${prefixFor(line.op)} ${line.text}`)
.join('\n');
return (
<div className={cx(styles.diff, styles.expanded, styles.unified)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Diff</span>
<div className={styles.diffHeaderActions}>
<CopyButton text={unifiedText} label="diff" />
</div>
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => (
<DiffLine
// stable enough — input strings are immutable for the view's lifetime
// eslint-disable-next-line react/no-array-index-key
key={idx}
op={line.op}
text={line.text}
prefix={prefixFor(line.op)}
/>
))}
</div>
</div>
);
}
// Split view: align side-by-side using the LCS result. `equal` lines
// appear on both sides; `remove` only on the left, `add` only on the
// right (with an empty placeholder on the missing side so rows stay
// aligned vertically).
return (
<div className={cx(styles.diff, styles.expanded)}>
<div className={cx(styles.diffBlock, styles.before)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Before</span>
<CopyButton text={beforeText} label="before" />
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => {
const op = line.op === 'add' ? 'placeholder' : line.op;
const text = line.op === 'add' ? '' : line.text;
// eslint-disable-next-line react/no-array-index-key
return <DiffLine key={idx} op={op} text={text} />;
})}
</div>
</div>
<div className={cx(styles.diffBlock, styles.after)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>After</span>
<CopyButton text={afterText} label="after" />
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => {
const op = line.op === 'remove' ? 'placeholder' : line.op;
const text = line.op === 'remove' ? '' : line.text;
// eslint-disable-next-line react/no-array-index-key
return <DiffLine key={idx} op={op} text={text} />;
})}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Line diff — small LCS-based implementation. Avoids pulling in `diff`
// since the inputs are JSON.stringify output (line-oriented, typically
// well under a few hundred lines for resource diffs).
// ---------------------------------------------------------------------------
type LineOp = 'equal' | 'add' | 'remove';
type RenderOp = LineOp | 'placeholder';
interface DiffLineEntry {
op: LineOp;
text: string;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function computeLineDiff(before: string, after: string): DiffLineEntry[] {
if (before === after) {
return splitLines(before).map((text) => ({ op: 'equal', text }));
}
const a = splitLines(before);
const b = splitLines(after);
const m = a.length;
const n = b.length;
// dp[i][j] = length of LCS between a[0..i] and b[0..j]
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
);
for (let i = 1; i <= m; i += 1) {
for (let j = 1; j <= n; j += 1) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to produce the diff
const result: DiffLineEntry[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
result.push({ op: 'equal', text: a[i - 1] });
i -= 1;
j -= 1;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
result.push({ op: 'remove', text: a[i - 1] });
i -= 1;
} else {
result.push({ op: 'add', text: b[j - 1] });
j -= 1;
}
}
while (i > 0) {
result.push({ op: 'remove', text: a[i - 1] });
i -= 1;
}
while (j > 0) {
result.push({ op: 'add', text: b[j - 1] });
j -= 1;
}
result.reverse();
return result;
}
function splitLines(text: string): string[] {
if (text === '') {
return [];
}
return text.split('\n');
}
function prefixFor(op: LineOp): string {
if (op === 'add') {
return '+';
}
if (op === 'remove') {
return '-';
}
return ' ';
}
interface DiffLineProps {
op: RenderOp;
text: string;
/** Optional gutter prefix used in unified view (`+` / `-` / ` `). */
prefix?: string;
}
function DiffLine({ op, text, prefix }: DiffLineProps): JSX.Element {
const cls = cx(styles.diffLine, {
[styles.diffLineAdd]: op === 'add',
[styles.diffLineRemove]: op === 'remove',
[styles.diffLinePlaceholder]: op === 'placeholder',
});
return (
<div className={cls}>
{prefix !== undefined && (
<span className={styles.diffGutter} aria-hidden="true">
{prefix}
</span>
)}
<span className={styles.diffLineText}>{text || ' '}</span>
</div>
);
}
interface CopyButtonProps {
text: string;
label: string;
}
function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
// Track the timeout so an unmount mid-flight doesn't try to setState on
// a dead component (and so a rapid re-click resets the 1.5s window).
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
const handleCopy = (): void => {
copyToClipboard(text);
setCopied(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopy}
title={copied ? `Copied ${label}` : `Copy ${label}`}
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
);
}

View File

@@ -46,6 +46,19 @@ $radius: 4px;
min-height: 0 !important;
width: auto !important;
}
// `auto` chips are derived from the URL (current page) — visually
// distinguished by a dashed border + slightly muted text so the user
// can tell them apart from explicit @-mentions. Tighter padding /
// font-size keeps them visually subordinate to user `@`-picks.
&.auto {
border-style: dashed;
color: var(--l2-foreground);
background: transparent;
font-size: 10px;
padding: 2px 4px 2px 6px;
gap: 3px;
}
}
.contextTagContent {

View File

@@ -44,6 +44,89 @@ interface ChatInputProps {
onCancel?: () => void;
disabled?: boolean;
isStreaming?: boolean;
/**
* URL-derived `source: 'auto'` contexts representing the page the user is
* currently looking at. Rendered as chips alongside the user's `@`-mention
* picks and merged into the outgoing `contexts` array.
*/
autoContexts?: MessageContext[];
/**
* Called when the user dismisses an auto-context chip. The parent owns
* the dismissed set and is responsible for filtering the next render's
* `autoContexts` to exclude the key.
*/
onDismissAutoContext?: (key: string) => void;
}
/** Stable identity for an auto-context entry — used as React key + dismissal id. */
export function autoContextKey(ctx: MessageContext): string {
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
return `auto:${ctx.type}:${ctx.resourceId ?? ''}:${page ?? ''}`;
}
/**
* Friendly label for an auto-derived context chip. We don't fetch resource
* names from the URL alone, so we lean on the page identity that already
* lives in `metadata.page`, falling back to the resource type.
*/
function autoContextLabel(ctx: MessageContext): string {
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
switch (page) {
case 'dashboard_detail':
return 'Current dashboard';
case 'panel_edit':
return 'Editing panel';
case 'panel_fullscreen':
return 'Panel (fullscreen)';
case 'dashboard_list':
return 'Dashboards';
case 'alert_edit':
return 'Editing alert';
case 'alert_new':
return 'New alert';
case 'alerts_triggered':
return 'Triggered alerts';
case 'alert_list':
return 'Alerts';
case 'service_detail':
return 'Current service';
case 'services_list':
return 'Services';
case 'logs_explorer':
return 'Logs explorer';
case 'log_detail':
return 'Log details';
case 'traces_explorer':
return 'Traces explorer';
case 'trace_detail':
return 'Trace details';
case 'metrics_explorer':
return 'Metrics explorer';
default:
return ctx.type;
}
}
/** Capitalised category badge text — e.g. "Dashboard", "Logs explorer". */
function autoContextCategory(ctx: MessageContext): string {
switch (ctx.type) {
case 'dashboard':
return 'Dashboard';
case 'alert':
return 'Alert';
case 'service':
return 'Service';
case 'logs_explorer':
return 'Logs';
case 'traces_explorer':
return 'Traces';
case 'metrics_explorer':
return 'Metrics';
case 'saved_view':
return 'Saved view';
default:
return ctx.type;
}
}
const MAX_INPUT_LENGTH = 20000;
@@ -129,6 +212,8 @@ export default function ChatInput({
onCancel,
disabled,
isStreaming = false,
autoContexts,
onDismissAutoContext,
}: ChatInputProps): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -141,6 +226,15 @@ export default function ChatInput({
const [isContextPickerOpen, setIsContextPickerOpen] = useState(false);
const [activeContextCategory, setActiveContextCategory] =
useState<ContextCategory>('Dashboards');
// When the picker was opened by typing `@` in the textarea, this holds the
// span of `@<query>` (start / end indices into `text`). Used both for live
// filtering of the entity list and for splicing the trigger out of the
// text once the user picks an item. `null` when the picker is opened via
// the "Add Context" button (no trigger to strip, no query to filter).
const [mentionRange, setMentionRange] = useState<{
start: number;
end: number;
} | null>(null);
const [servicesTimeRange] = useState(() => {
const now = Date.now();
return {
@@ -164,35 +258,49 @@ export default function ChatInput({
const atIndex = beforeCaret.lastIndexOf('@');
if (atIndex < 0) {
setIsContextPickerOpen(false);
setMentionRange(null);
return;
}
const query = beforeCaret.slice(atIndex + 1);
if (/\s/.test(query)) {
setIsContextPickerOpen(false);
setMentionRange(null);
return;
}
setIsContextPickerOpen(true);
setMentionRange({ start: atIndex, end: caret });
},
[],
);
const toggleContextSelection = useCallback(
(category: ContextCategory, entityId: string, contextValue: string) => {
setSelectedContexts((prev) => {
const alreadySelected = prev.some(
(item) => item.category === category && item.entityId === entityId,
);
const wasSelected = selectedContexts.some(
(item) => item.category === category && item.entityId === entityId,
);
if (alreadySelected) {
setSelectedContexts((prev) => {
if (wasSelected) {
return prev.filter(
(item) => !(item.category === category && item.entityId === entityId),
);
}
return [...prev, { category, entityId, value: contextValue }];
});
// When the user picks an item via the `@` trigger, splice the
// `@<query>` span out of the textarea so their prose stays clean.
// Skip on remove (no trigger to strip) and when the picker was
// opened from the "Add Context" button (no mention range tracked).
if (!wasSelected && mentionRange) {
const next =
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
}
},
[],
[mentionRange, selectedContexts, text],
);
// Focus the textarea when this component mounts (panel/modal open)
@@ -217,9 +325,12 @@ export default function ChatInput({
}),
);
const contexts = selectedContexts
const userContexts = selectedContexts
.map(toMessageContext)
.filter((context): context is MessageContext => context !== null);
// Auto contexts come first so the agent reads "current page" before
// any explicit @-mentions when both are present.
const contexts = [...(autoContexts ?? []), ...userContexts];
const payload = capText(trimmed);
onSend(
@@ -232,7 +343,7 @@ export default function ChatInput({
setPendingFiles([]);
setSelectedContexts([]);
textareaRef.current?.focus();
}, [text, pendingFiles, onSend, selectedContexts, capText]);
}, [text, pendingFiles, onSend, selectedContexts, autoContexts, capText]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -303,13 +414,21 @@ export default function ChatInput({
isError: isDashboardsError,
} = useGetAllDashboard();
// Enable list fetches both for the picker (when the corresponding category
// is open) AND for auto-context chips that need to resolve a name from a
// resource id. Dashboards is unconditional since the underlying hook
// has no `enabled` flag.
const needsAlertResolve = (autoContexts ?? []).some(
(c) => c.type === 'alert' && Boolean(c.resourceId),
);
const {
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRules({
query: {
enabled: activeContextCategory === 'Alerts',
enabled: activeContextCategory === 'Alerts' || needsAlertResolve,
},
});
@@ -328,6 +447,43 @@ export default function ChatInput({
},
});
/**
* Resolves an auto-context to a human label: dashboard title, alert name,
* service name (the service `resourceId` IS the name), or a generic page
* label as fallback while the lookup data is still loading.
*/
const resolveAutoContextName = useCallback(
(ctx: MessageContext): string => {
if (ctx.type === 'service' && ctx.resourceId) {
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const dash = dashboardsResponse?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const rule = alertsResponse?.data?.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
}
const page = (
ctx.metadata as { page?: string; traceId?: string } | null | undefined
)?.page;
if (page === 'trace_detail') {
const traceId = (ctx.metadata as { traceId?: string } | null | undefined)
?.traceId;
if (traceId) {
return `${traceId.slice(0, 8)}`;
}
}
return autoContextLabel(ctx);
},
[dashboardsResponse, alertsResponse],
);
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
@@ -374,8 +530,17 @@ export default function ChatInput({
},
};
const filteredContextOptions =
contextEntitiesByCategory[activeContextCategory];
// Type-ahead filter against the `@<query>` typed in the textarea. When the
// picker was opened from the "Add Context" button there's no query, so we
// show every entity for the active category.
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
: '';
const filteredContextOptions = mentionQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(mentionQuery),
)
: contextEntitiesByCategory[activeContextCategory];
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
contextCategoryStateByCategory[activeContextCategory];
const currentLength = text.length;
@@ -402,8 +567,39 @@ export default function ChatInput({
</div>
)}
{selectedContexts.length > 0 && (
{(selectedContexts.length > 0 ||
(autoContexts && autoContexts.length > 0)) && (
<div className={styles.contextTags}>
{autoContexts?.map((ctx) => {
const key = autoContextKey(ctx);
const label = resolveAutoContextName(ctx);
const category = autoContextCategory(ctx);
return (
<div key={key} className={cx(styles.contextTag, styles.auto)}>
<div className={styles.contextTagContent}>
<Badge
color="secondary"
variant="outline"
className={styles.contextTagCategory}
>
{category}
</Badge>
<span className={styles.contextTagLabel}>{label}</span>
</div>
{onDismissAutoContext && (
<Button
variant="link"
size="icon"
color="secondary"
className={styles.contextTagRemove}
onClick={(): void => onDismissAutoContext(key)}
aria-label={`Remove ${category}: ${label} context`}
prefix={<X size={10} />}
></Button>
)}
</div>
);
})}
{selectedContexts.map((contextItem) => (
<div
key={`${contextItem.category}:${contextItem.entityId}`}

View File

@@ -97,6 +97,14 @@ $radius: 4px;
cursor: pointer;
}
// Constrain the Radix-based SelectContent popover so it never grows wider
// than the trigger button. `--radix-select-trigger-width` is set by Radix
// at the popper layer when `position: 'popper'` (the default).
.selectContent {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.radioGroup,
.checkboxGroup {
display: flex;

View File

@@ -1,6 +1,16 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui';
import {
Button,
Checkbox,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui';
import type {
ClarificationEventDTO,
ClarificationFieldEventDTO,
@@ -11,6 +21,11 @@ import { useAIAssistantStore } from '../store/useAIAssistantStore';
import styles from './ClarificationForm.module.scss';
/** Sentinel emitted by the select dropdown when the user picks the custom slot. */
const CUSTOM_OPTION_SENTINEL = '__signoz_ai_custom__';
/** User-facing label for the synthetic "type your own answer" option. */
const CUSTOM_OPTION_LABEL = 'Other (type your own)';
interface ClarificationFormProps {
conversationId: string;
clarification: ClarificationEventDTO;
@@ -131,67 +146,176 @@ interface FieldInputProps {
}
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
const { id, type, label, required, options } = field;
const { id, type, label, required, options, allowCustom } = field;
// Local UI state for the synthetic "custom" option on select / radio /
// checkbox fields with `allowCustom`. The free-text input only renders
// when this is true; the typed value is what's actually sent up via
// `onChange` (never the sentinel / "Other" label).
const [isCustom, setIsCustom] = useState(false);
const [customValue, setCustomValue] = useState('');
// Render the select if the field has options OR if the server marked it
// `allowCustom` (in which case the dropdown still appears with just the
// "Other (type your own)" entry — a plain `options: null` would
// otherwise fall through to the bare text-input renderer).
if (type === 'select' && (options || allowCustom)) {
const handleSelectChange = (next: string | string[]): void => {
// `multiple` is off → callback receives a single string. The wider
// `string | string[]` typing comes from the shared Select root.
const picked = Array.isArray(next) ? (next[0] ?? '') : next;
if (picked === CUSTOM_OPTION_SENTINEL) {
setIsCustom(true);
onChange(customValue);
} else {
setIsCustom(false);
onChange(picked);
}
};
if (type === 'select' && options) {
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && <span className={styles.required}>*</span>}
</label>
<select
id={id}
className={styles.select}
value={String(value ?? '')}
onChange={(e): void => onChange(e.target.value)}
<Select
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
onChange={handleSelectChange}
>
<option value="">Select</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<SelectTrigger id={id} placeholder="Select…" />
{/* Pin the dropdown width to the trigger via Radix's
`--radix-select-trigger-width`; otherwise the popover
sizes to its widest item and looks misaligned. */}
<SelectContent className={styles.selectContent}>
{options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
{allowCustom && (
<SelectItem value={CUSTOM_OPTION_SENTINEL}>
{CUSTOM_OPTION_LABEL}
</SelectItem>
)}
</SelectContent>
</Select>
{isCustom && (
<Input
type="text"
className={styles.input}
placeholder="Enter a custom value"
value={customValue}
onChange={(e): void => {
setCustomValue(e.target.value);
onChange(e.target.value);
}}
/>
)}
</div>
);
}
if (type === 'radio' && options) {
// Same fallback logic as the select branch — render the radio group
// when there are options OR when the field is `allowCustom` only.
if (type === 'radio' && (options || allowCustom)) {
const handleRadioChange = (next: string): void => {
if (next === CUSTOM_OPTION_SENTINEL) {
setIsCustom(true);
onChange(customValue);
} else {
setIsCustom(false);
onChange(next);
}
};
const radioValue = isCustom
? CUSTOM_OPTION_SENTINEL
: typeof value === 'string'
? value
: '';
return (
<div className={styles.field}>
<span className={styles.label}>
{label}
{required && <span className={styles.required}>*</span>}
</span>
<div className={styles.radioGroup}>
{options.map((opt) => (
<label key={opt} className={styles.radioLabel}>
<input
type="radio"
name={id}
value={opt}
checked={value === opt}
onChange={(): void => onChange(opt)}
className={styles.radio}
/>
<RadioGroup
name={id}
value={radioValue}
onChange={handleRadioChange}
className={styles.radioGroup}
>
{options?.map((opt) => (
<RadioGroupItem
key={opt}
value={opt}
containerClassName={styles.radioLabel}
>
{opt}
</label>
</RadioGroupItem>
))}
</div>
{allowCustom && (
<RadioGroupItem
value={CUSTOM_OPTION_SENTINEL}
containerClassName={styles.radioLabel}
>
{CUSTOM_OPTION_LABEL}
</RadioGroupItem>
)}
</RadioGroup>
{isCustom && (
<Input
type="text"
className={styles.input}
placeholder="Enter a custom value"
value={customValue}
onChange={(e): void => {
setCustomValue(e.target.value);
onChange(e.target.value);
}}
/>
)}
</div>
);
}
if (type === 'checkbox' && options) {
// Same fallback logic as the select branch — render the checkbox group
// when there are options OR when the field is `allowCustom` only.
if (type === 'checkbox' && (options || allowCustom)) {
const selected = Array.isArray(value) ? (value as string[]) : [];
const toggle = (opt: string): void => {
// Anything in the value array that isn't one of the predefined options
// is treated as a custom entry — we keep at most one custom entry,
// driven by the local `customValue` + `isCustom` state below.
const regularSelected = selected.filter((v) => options?.includes(v));
const toggleRegular = (opt: string): void => {
const nextRegular = regularSelected.includes(opt)
? regularSelected.filter((v) => v !== opt)
: [...regularSelected, opt];
onChange(
selected.includes(opt)
? selected.filter((v) => v !== opt)
: [...selected, opt],
isCustom && customValue ? [...nextRegular, customValue] : nextRegular,
);
};
const toggleCustom = (): void => {
if (isCustom) {
setIsCustom(false);
onChange(regularSelected);
} else {
setIsCustom(true);
onChange(customValue ? [...regularSelected, customValue] : regularSelected);
}
};
const updateCustomValue = (next: string): void => {
setCustomValue(next);
if (isCustom) {
onChange(next ? [...regularSelected, next] : regularSelected);
}
};
return (
<div className={styles.field}>
<span className={styles.label}>
@@ -199,18 +323,35 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
{required && <span className={styles.required}>*</span>}
</span>
<div className={styles.checkboxGroup}>
{options.map((opt) => (
<label key={opt} className={styles.checkboxLabel}>
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={(): void => toggle(opt)}
className={styles.checkbox}
/>
{options?.map((opt) => (
<Checkbox
key={opt}
className={styles.checkboxLabel}
value={regularSelected.includes(opt)}
onChange={(): void => toggleRegular(opt)}
>
{opt}
</label>
</Checkbox>
))}
{allowCustom && (
<Checkbox
className={styles.checkboxLabel}
value={isCustom}
onChange={toggleCustom}
>
{CUSTOM_OPTION_LABEL}
</Checkbox>
)}
</div>
{isCustom && (
<Input
type="text"
className={styles.input}
placeholder="Enter a custom value"
value={customValue}
onChange={(e): void => updateCustomValue(e.target.value)}
/>
)}
</div>
);
}
@@ -222,7 +363,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
{label}
{required && <span className={styles.required}>*</span>}
</label>
<input
<Input
id={id}
type={type === 'number' ? 'number' : 'text'}
className={styles.input}

View File

@@ -1,6 +1,6 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { Button, Tooltip } from '@signozhq/ui';
import { Button, Input, Tooltip } from '@signozhq/ui';
import {
Archive,
ArchiveRestore,
@@ -130,7 +130,7 @@ export default function ConversationItem({
<div className={styles.body}>
{isEditing ? (
<input
<Input
ref={inputRef}
className={styles.input}
value={editValue}

View File

@@ -44,7 +44,7 @@ $radius: 4px;
.bubble {
border-radius: $radius;
padding: 4px 8px;
font-size: 13px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 153.846% */

View File

@@ -0,0 +1,333 @@
import type { MessageContext } from 'api/ai/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { matchPath } from 'react-router-dom';
/**
* Resolves the page the user is currently on into structured `MessageContext`
* entries with `source: 'auto'`. The agent uses these as implicit context
* (e.g. "the user is currently looking at dashboard X with this time range
* and this query").
*
* **URL-only.** All inputs come from `pathname` + `search`; we never reach
* into Redux / context. SigNoz already encodes most page state into the URL
* (`compositeQuery`, `startTime`/`endTime`, `viewName`/`viewKey`,
* `variables`, `expandedWidgetId`, `activeLogId`, …), so URL parsing is
* sufficient. Anything not in the URL is simply omitted from `metadata` —
* the server applies its own defaults.
*
* Returns `[]` when no mapping exists, so callers can spread the result
* unconditionally before user-picked contexts.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function getAutoContexts(
pathname: string,
search: string,
): MessageContext[] {
const params = new URLSearchParams(search);
const sharedMetadata = collectSharedMetadata(params);
// ── Dashboards ────────────────────────────────────────────────────────────
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
pathname,
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
);
if (widgetMatch) {
return [
{
source: 'auto',
type: 'dashboard',
resourceId: widgetMatch.params.dashboardId,
metadata: {
page: 'panel_edit',
widgetId: widgetMatch.params.widgetId,
...sharedMetadata,
},
},
];
}
// Dashboard detail — `/dashboard/:dashboardId`. The `expandedWidgetId`
// query param signals the panel-fullscreen overlay; otherwise it's the
// plain dashboard view.
const dashboardMatch = matchPath<{ dashboardId: string }>(pathname, {
path: ROUTES.DASHBOARD,
exact: true,
});
if (dashboardMatch) {
const expandedWidgetId = params.get(QueryParams.expandedWidgetId);
if (expandedWidgetId) {
return [
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardMatch.params.dashboardId,
metadata: {
page: 'panel_fullscreen',
widgetId: expandedWidgetId,
...sharedMetadata,
},
},
];
}
return [
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardMatch.params.dashboardId,
metadata: {
page: 'dashboard_detail',
...sharedMetadata,
},
},
];
}
// Dashboard list — `/dashboard`.
if (matchPath(pathname, { path: ROUTES.ALL_DASHBOARD, exact: true })) {
return [
{
source: 'auto',
type: 'dashboard',
resourceId: null,
metadata: { page: 'dashboard_list' },
},
];
}
// ── Alerts ────────────────────────────────────────────────────────────────
// Alert edit — `/alerts/edit?ruleId=…`.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
return [
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit' },
},
];
}
}
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new' },
},
];
}
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alerts_triggered',
...sharedMetadata,
},
},
];
}
if (matchPath(pathname, { path: ROUTES.LIST_ALL_ALERT, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_list' },
},
];
}
// ── Services ──────────────────────────────────────────────────────────────
// Service detail (covers sub-routes like top-level-operations) —
// `/services/:servicename[/...]`.
const serviceMatch = matchPath<{ servicename: string }>(pathname, {
path: ROUTES.SERVICE_METRICS,
exact: false,
});
if (serviceMatch?.params.servicename) {
return [
{
source: 'auto',
type: 'service',
resourceId: serviceMatch.params.servicename,
metadata: {
page: 'service_detail',
...sharedMetadata,
},
},
];
}
// Services list — `/services`.
if (matchPath(pathname, { path: ROUTES.APPLICATION, exact: true })) {
return [
{
source: 'auto',
type: 'service',
resourceId: null,
metadata: {
page: 'services_list',
...sharedMetadata,
},
},
];
}
// ── Logs ──────────────────────────────────────────────────────────────────
if (matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false })) {
const activeLogId = params.get(QueryParams.activeLogId);
// `?activeLogId=…` indicates a log-detail panel is open. Per the
// schema, log_detail requires payload fields (timestamp, service,
// body) we can't recover from the URL alone, so we still emit the
// surrounding logs_explorer page context — the panel registry can
// layer richer log_detail context on top in a follow-up.
return [
{
source: 'auto',
type: 'logs_explorer',
resourceId: null,
metadata: {
page: 'logs_explorer',
...sharedMetadata,
...(activeLogId ? { activeLogId } : {}),
},
},
];
}
// ── Traces ────────────────────────────────────────────────────────────────
// Trace detail — `/trace/:id`. Treated as a detail-as-metadata page
// (resourceId null, `traceId` lives in metadata).
const traceMatch = matchPath<{ id: string }>(pathname, {
path: ROUTES.TRACE_DETAIL,
exact: true,
});
if (traceMatch?.params.id) {
const spanId = params.get(QueryParams.selectedTimelineQuery);
return [
{
source: 'auto',
type: 'traces_explorer',
resourceId: null,
metadata: {
page: 'trace_detail',
traceId: traceMatch.params.id,
...(spanId ? { spanId } : {}),
...sharedMetadata,
},
},
];
}
if (matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false })) {
return [
{
source: 'auto',
type: 'traces_explorer',
resourceId: null,
metadata: {
page: 'traces_explorer',
...sharedMetadata,
},
},
];
}
// ── Metrics ───────────────────────────────────────────────────────────────
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_EXPLORER, exact: false })
) {
return [
{
source: 'auto',
type: 'metrics_explorer',
resourceId: null,
metadata: {
page: 'metrics_explorer',
...sharedMetadata,
},
},
];
}
return [];
}
/**
* Pulls metadata fields that any page may carry in its query string —
* `timeRange`, `query`, saved-view selectors, dashboard variables. Each
* piece is omitted when the URL doesn't carry it (rather than being filled
* in with a stale default).
*/
function collectSharedMetadata(
params: URLSearchParams,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
// Time range — emit only when both bounds are explicit. SigNoz writes
// `startTime` / `endTime` in milliseconds when the user picks a custom
// range; relative ranges (`relativeTime=15m`) are left out because the
// server applies its own freshly-anchored window.
const startTime = numericParam(params, QueryParams.startTime);
const endTime = numericParam(params, QueryParams.endTime);
if (startTime !== null && endTime !== null) {
out.timeRange = { start: startTime, end: endTime };
}
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
}
// Saved view selectors (logs / traces explorer) and dashboard variables.
const viewKey = params.get(QueryParams.viewKey);
if (viewKey) {
out.savedViewId = viewKey;
}
const viewName = params.get(QueryParams.viewName);
if (viewName) {
out.savedViewName = viewName;
}
const variablesRaw = params.get(QueryParams.variables);
if (variablesRaw) {
try {
out.variables = JSON.parse(decodeURIComponent(variablesRaw));
} catch {
// Same fallback as compositeQuery — keep silent on malformed JSON.
}
}
return out;
}
function numericParam(params: URLSearchParams, key: string): number | null {
const raw = params.get(key);
if (raw === null) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}

View File

@@ -287,14 +287,11 @@ async function runStreamingLoop(
set((s) => {
const st = s.streams[conversationId];
if (st) {
st.pendingApproval = {
approvalId: event.approvalId,
executionId: event.executionId,
actionType: event.actionType,
resourceType: event.resourceType,
summary: event.summary,
diff: event.diff ?? null,
};
// The SSE event is already an `ApprovalEventDTO`, the same
// shape the slot is typed against — pass through verbatim
// rather than re-projecting (which previously dropped any
// fields the projection didn't enumerate explicitly).
st.pendingApproval = event;
st.streamingStatus = 'awaiting_approval';
st.isStreaming = false;
}
@@ -304,20 +301,11 @@ async function runStreamingLoop(
set((s) => {
const st = s.streams[conversationId];
if (st) {
st.pendingClarification = {
clarificationId: event.clarificationId,
executionId: event.executionId,
message: event.message,
discoveredContext: event.discoveredContext ?? null,
fields: (event.fields ?? []).map((f) => ({
id: f.id,
type: f.type,
label: f.label,
required: f.required ?? false,
options: f.options ?? null,
default: f.default ?? null,
})),
};
// Same rationale as the approval branch — `event` is a
// `ClarificationEventDTO` whose `fields` already carry
// `allowCustom` (which the previous manual projection
// silently stripped).
st.pendingClarification = event;
st.streamingStatus = 'awaiting_clarification';
st.isStreaming = false;
}

View File

@@ -4496,10 +4496,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
"@signozhq/ui@0.0.13":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.13.tgz#63e64d203df662a84ffa38ea18534ba2941fd051"
integrity sha512-YFFFNTnF9ZG9ddKhFV6feQRLUBg6vuDTcvWaStzfOCS0xF3ocd+Lpg73TpFkKidLx/Ij/TJm5u/tTobkdLyC8A==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"
@@ -4521,7 +4521,7 @@
clsx "^2.1.1"
cmdk "^1.1.1"
dayjs "^1.11.10"
lodash-es "^4.17.21"
lodash-es "^4.18.1"
motion "^11.11.17"
next-themes "^0.4.6"
nuqs "^2.8.9"
@@ -11914,6 +11914,11 @@ lodash-es@4, lodash-es@^4.17.21:
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-es@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz"