Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
82e64bad90 fix(dashboards): variables can be undefined when create new dashboard 2026-04-30 13:21:40 -03:00
371 changed files with 16651 additions and 60290 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -17273,9 +17273,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- ADMIN
- tokenizer:
- VIEWER
- ADMIN
summary: Get host info from Zeus.
tags:
- zeus

View File

@@ -1,20 +0,0 @@
---
description: Prefer SigNoz UI and icons across frontend code
globs: **/*.{ts,tsx,js,jsx}
alwaysApply: true
---
# UI Components and Icons Source of Truth
For all frontend implementation work in this repository:
- Always use UI primitives/components from `@signozhq/ui`.
- Always use icons from `@signozhq/icons`.
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
## Migration guidance
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"project": ["src/**/*.ts", "src/**/*.tsx"],
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"]
"ignore": ["src/api/generated/**/*.ts"]
}

39763
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

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.13",
"@signozhq/ui": "0.0.12",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -110,7 +110,6 @@
"react": "18.2.0",
"react-addons-update": "15.6.3",
"react-beautiful-dnd": "13.1.1",
"react-chartjs-2": "4",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
@@ -134,7 +133,6 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"remark-gfm": "^3.0.1",
"rollup-plugin-visualizer": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
@@ -200,7 +198,6 @@
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",
"@types/uuid": "^8.3.1",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"autoprefixer": "10.4.19",
"babel-plugin-styled-components": "^1.12.0",
"eslint-plugin-sonarjs": "4.0.2",
@@ -234,6 +231,7 @@
"ts-jest": "29.4.6",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",
"vite-plugin-image-optimizer": "2.0.3",
@@ -243,7 +241,7 @@
"*.(js|jsx|ts|tsx)": [
"oxlint --fix",
"oxfmt --write",
"sh -c tsgo --noEmit"
"sh scripts/typecheck-staged.sh"
],
"*.(scss|css)": [
"stylelint"

View File

@@ -1,9 +0,0 @@
# SigNoz AI Assistant
1. Chat interface (Side Drawer View)
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
2. Input Section
1. Users should be able to upload images / files to the chat

View File

@@ -0,0 +1,25 @@
files="";
# lint-staged will pass all files in $1 $2 $3 etc. iterate and concat.
for var in "$@"
do
files="$files \"$var\","
done
# create temporary tsconfig which includes only passed files
str="{
\"extends\": \"./tsconfig.json\",
\"include\": [ \"src/typings/**/*.ts\",\"src/**/*.d.ts\", \"./babel.config.js\", \"./jest.config.ts\", \"./.eslintrc.js\",\"./__mocks__\",\"./public\",\"./tests\",\"./commitlint.config.ts\",\"./webpack.config.js\",\"./webpack.config.prod.js\",\"./jest.setup.ts\",\"./**/*.d.ts\",$files]
}"
echo $str > tsconfig.tmp
# run typecheck using temp config
tsc -p ./tsconfig.tmp
# capture exit code of tsc
code=$?
# delete temp config
rm ./tsconfig.tmp
exit $code

View File

@@ -99,13 +99,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <>{children}</>;
}
const isAIAssistantEnabled =
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
?.active ?? false;
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -212,30 +212,6 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
useEffect(() => {
if (!isLoggedInState || isFetchingFeatureFlags) {
return;
}
const isAIAssistantEnabled =
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
?.active ?? false;
setRoutes((prev) => {
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
if (isAIAssistantEnabled === hasAi) {
return prev;
}
if (isAIAssistantEnabled) {
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
if (!aiRoute) {
return prev;
}
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
}
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
});
}, [isLoggedInState, isFetchingFeatureFlags, featureFlags]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
@@ -245,8 +221,7 @@ function App(): JSX.Element {
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname.startsWith('/ai-assistant/')
pathname.startsWith('/public/dashboard/')
) {
window.Pylon?.('hideChatBubble');
} else {

View File

@@ -317,10 +317,3 @@ export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);
export const AIAssistantPage = Loadable(
() =>
import(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);

View File

@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
@@ -497,13 +496,6 @@ const routes: AppRoutes[] = [
key: 'API_MONITORING',
isPrivate: true,
},
{
path: ROUTES.AI_ASSISTANT,
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -1,455 +0,0 @@
/**
* AI Assistant API client.
*
* Flow:
* 1. POST /api/v1/assistant/threads → { threadId }
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
*
* For subsequent messages in the same thread, repeat steps 23.
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
* to resume, which each return a new executionId to open a fresh SSE stream.
*
* Types in this file re-use the OpenAPI-generated DTOs in
* `src/api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas.ts`.
* Local types are defined only when the UI needs a different shape — for
* example, the SSE event union adds a literal `type` discriminator that the
* generated event DTOs leave loose.
*
* REST calls go through `AIAssistantInstance` (an axios instance configured
* with the same interceptor stack as the rest of the app) — that gives them
* automatic 401-then-rotate behaviour for free. Only the SSE call is still
* a raw `fetch` because axios doesn't expose `ReadableStream`; that one
* path gets its own small auth wrapper.
*/
import axios from 'axios';
import getLocalStorageApi from 'api/browser/localstorage/get';
import { Logout } from 'api/utils';
import rotateSession from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import type {
ActionResultResponseDTO,
ApprovalEventDTO,
ApproveResponseDTO,
CancelResponseDTO,
ClarificationEventDTO,
ClarifyResponseDTO,
ConversationEventDTO,
CreateMessageResponseDTO,
CreateThreadResponseDTO,
DoneEventDTO,
ErrorEventDTO,
ExecutionStateDTO,
FeedbackRatingDTO,
ListThreadsApiV1AssistantThreadsGetArchived,
ListThreadsApiV1AssistantThreadsGetParams,
MessageContextDTO,
MessageContextDTOSource,
MessageContextDTOType,
MessageEventDTO,
MessageSummaryDTO,
StatusEventDTO,
ThinkingEventDTO,
ThreadDetailResponseDTO,
ThreadListResponseDTO,
ThreadSummaryDTO,
ToolCallEventDTO,
ToolResultEventDTO,
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { AI_BASE_URL, AIAssistantInstance } from './instance';
// ---------------------------------------------------------------------------
// SSE-only auth wrapper.
//
// REST calls go through `AIAssistantInstance` (axios) and get refresh-token
// behaviour from the shared `interceptorRejected`. The SSE call has to use
// raw `fetch` (axios can't stream a `ReadableStream`), so it can't ride that
// interceptor — this small wrapper handles 401 at SSE open time by hitting
// the same rotate endpoint and replaying the request once.
//
// In typical use a REST call (e.g. sendMessage / loadThread) precedes every
// stream open, so axios will already have refreshed the token and `fetch`
// just reads the fresh one from localStorage. The wrapper exists for the
// edge case where SSE is the first call to encounter a 401.
// ---------------------------------------------------------------------------
let pendingRotate: Promise<string | null> | null = null;
async function rotateAccessToken(): Promise<string | null> {
if (pendingRotate) {
return pendingRotate;
}
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '';
if (!refreshToken) {
return null;
}
pendingRotate = (async (): Promise<string | null> => {
try {
const response = await rotateSession({ refreshToken });
afterLogin(response.data.accessToken, response.data.refreshToken, true);
return response.data.accessToken;
} catch {
Logout();
return null;
} finally {
pendingRotate = null;
}
})();
return pendingRotate;
}
async function fetchSSEWithAuth(
url: string,
signal?: AbortSignal,
): Promise<Response> {
const send = async (token: string | null): Promise<Response> =>
fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal,
});
const initialToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
const res = await send(initialToken);
if (res.status !== 401) {
return res;
}
const refreshed = await rotateAccessToken();
if (!refreshed) {
return res;
}
return send(refreshed);
}
// ---------------------------------------------------------------------------
// SSE event types
//
// The generated event DTOs each declare `type?: string` (loose). The UI needs
// a discriminated union, so we intersect each variant with a string-literal
// `type` to enable narrowing via `event.type === 'status'`.
// ---------------------------------------------------------------------------
export type SSEEvent =
| (StatusEventDTO & { type: 'status' })
| (MessageEventDTO & { type: 'message' })
| (ThinkingEventDTO & { type: 'thinking' })
| (ToolCallEventDTO & { type: 'tool_call' })
| (ToolResultEventDTO & { type: 'tool_result' })
| (ApprovalEventDTO & { type: 'approval' })
| (ClarificationEventDTO & { type: 'clarification' })
| (ErrorEventDTO & { type: 'error' })
| (ConversationEventDTO & { type: 'conversation' })
| (DoneEventDTO & { type: 'done' });
/** String-literal view of `ExecutionStateDTO` for ergonomic comparisons. */
export type ExecutionState = `${ExecutionStateDTO}`;
// ---------------------------------------------------------------------------
// Re-exported DTOs — the wire shape, used directly without remapping.
// ---------------------------------------------------------------------------
export type ThreadSummary = ThreadSummaryDTO;
export type ThreadListResponse = ThreadListResponseDTO;
export type ThreadDetailResponse = ThreadDetailResponseDTO;
export type MessageSummary = MessageSummaryDTO;
export type CancelResponse = CancelResponseDTO;
/**
* Construction-friendly view of `MessageContextDTO`: enum fields are widened
* to their string-literal unions so call-sites can pass `'mention'` instead
* of `MessageContextDTOSource.mention`.
*/
export type MessageContext = Omit<MessageContextDTO, 'source' | 'type'> & {
source: `${MessageContextDTOSource}`;
type: `${MessageContextDTOType}`;
};
/** Construction-friendly view of `ListThreadsApiV1AssistantThreadsGetParams`. */
export type ListThreadsOptions = Omit<
ListThreadsApiV1AssistantThreadsGetParams,
'archived'
> & {
archived?: `${ListThreadsApiV1AssistantThreadsGetArchived}`;
};
/** String-literal view of `FeedbackRatingDTO` so call-sites can pass `'positive'`/`'negative'`. */
export type FeedbackRating = `${FeedbackRatingDTO}`;
// ---------------------------------------------------------------------------
// Thread listing & detail
// ---------------------------------------------------------------------------
export async function listThreads(
options: ListThreadsOptions = {},
): Promise<ThreadListResponse> {
const {
archived = 'false',
limit = 20,
cursor = null,
sort = 'updated_desc',
} = options;
const response = await AIAssistantInstance.get<ThreadListResponse>(
'/threads',
{
params: {
archived,
limit,
sort,
...(cursor ? { cursor } : {}),
},
},
);
return response.data;
}
export async function updateThread(
threadId: string,
update: { title?: string | null; archived?: boolean | null },
): Promise<ThreadSummary> {
const response = await AIAssistantInstance.patch<ThreadSummary>(
`/threads/${threadId}`,
update,
);
return response.data;
}
export async function getThreadDetail(
threadId: string,
): Promise<ThreadDetailResponse> {
const response = await AIAssistantInstance.get<ThreadDetailResponse>(
`/threads/${threadId}`,
);
return response.data;
}
// ---------------------------------------------------------------------------
// Step 1 — Create thread
// POST /api/v1/assistant/threads → { threadId }
// ---------------------------------------------------------------------------
export async function createThread(signal?: AbortSignal): Promise<string> {
const response = await AIAssistantInstance.post<CreateThreadResponseDTO>(
'/threads',
{},
{ signal },
);
return response.data.threadId;
}
// ---------------------------------------------------------------------------
// Step 2 — Send message
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
// ---------------------------------------------------------------------------
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
async function getActiveExecutionId(threadId: string): Promise<string | null> {
try {
const response = await AIAssistantInstance.get<ThreadDetailResponseDTO>(
`/threads/${threadId}`,
);
return response.data.activeExecutionId ?? null;
} catch {
return null;
}
}
export async function sendMessage(
threadId: string,
content: string,
contexts?: MessageContext[],
signal?: AbortSignal,
): Promise<string> {
try {
const response = await AIAssistantInstance.post<CreateMessageResponseDTO>(
`/threads/${threadId}/messages`,
{
content,
...(contexts && contexts.length > 0 ? { contexts } : {}),
},
{ signal },
);
return response.data.executionId;
} catch (err) {
// Thread already has an active execution — reconnect to it instead of
// failing the user's send.
if (axios.isAxiosError(err) && err.response?.status === 409) {
const executionId = await getActiveExecutionId(threadId);
if (executionId) {
return executionId;
}
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Step 3 — Stream execution events
// GET /api/v1/assistant/executions/{executionId}/events → SSE
// ---------------------------------------------------------------------------
function parseSSELine(line: string): SSEEvent | null {
if (!line.startsWith('data: ')) {
return null;
}
const json = line.slice('data: '.length).trim();
if (!json || json === '[DONE]') {
return null;
}
try {
return JSON.parse(json) as SSEEvent;
} catch {
return null;
}
}
function parseSSEChunk(chunk: string): SSEEvent[] {
return chunk
.split('\n\n')
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
.map(parseSSELine)
.filter((e): e is SSEEvent => e !== null);
}
async function* readSSEReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
let lineBuffer = '';
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
lineBuffer += decoder.decode(value, { stream: true });
const parts = lineBuffer.split('\n\n');
lineBuffer = parts.pop() ?? '';
yield* parts.flatMap(parseSSEChunk);
}
yield* parseSSEChunk(lineBuffer);
} finally {
reader.releaseLock();
}
}
export async function* streamEvents(
executionId: string,
signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
const res = await fetchSSEWithAuth(
`${AI_BASE_URL}/executions/${executionId}/events`,
signal,
);
if (!res.ok || !res.body) {
throw new Error(`SSE stream failed: ${res.status} ${res.statusText}`);
}
yield* readSSEReader(res.body.getReader());
}
// ---------------------------------------------------------------------------
// Approval / Clarification / Cancel actions
// ---------------------------------------------------------------------------
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
export async function approveExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ApproveResponseDTO>(
'/approve',
{ approvalId },
{ signal },
);
return response.data.executionId;
}
/** Reject a pending action. */
export async function rejectExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<void> {
await AIAssistantInstance.post('/reject', { approvalId }, { signal });
}
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
export async function clarifyExecution(
clarificationId: string,
answers: Record<string, unknown>,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ClarifyResponseDTO>(
'/clarify',
{ clarificationId, answers },
{ signal },
);
return response.data.executionId;
}
export async function cancelExecution(
threadId: string,
signal?: AbortSignal,
): Promise<CancelResponse> {
const response = await AIAssistantInstance.post<CancelResponse>(
'/cancel',
{ threadId },
{ signal },
);
return response.data;
}
// ---------------------------------------------------------------------------
// Rollback actions — undo / revert / restore
// All three POST `{ actionMetadataId }` and return `ActionResultResponseDTO`.
// ---------------------------------------------------------------------------
async function postRollback(
endpoint: 'undo' | 'revert' | 'restore',
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> {
const response = await AIAssistantInstance.post<ActionResultResponseDTO>(
`/${endpoint}`,
{ actionMetadataId },
{ signal },
);
return response.data;
}
export const undoExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('undo', actionMetadataId, signal);
export const revertExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('revert', actionMetadataId, signal);
export const restoreExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('restore', actionMetadataId, signal);
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export async function submitFeedback(
messageId: string,
rating: FeedbackRating,
comment?: string,
): Promise<void> {
await AIAssistantInstance.post(`/messages/${messageId}/feedback`, {
rating,
comment: comment ?? null,
});
}

View File

@@ -1,41 +0,0 @@
import axios from 'axios';
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
// Direct URL to the AI backend — set VITE_AI_BACKEND_URL in .env (see vite.config `define`).
const AI_BACKEND = process.env.VITE_AI_BACKEND_URL || 'http://localhost:8001';
/** Path-only base for the AI Assistant API. */
export const AI_API_PATH = '/api/v1/assistant';
/** Full base URL used by the axios instance and the SSE fetch path. */
export const AI_BASE_URL = `${AI_BACKEND}${AI_API_PATH}`;
/**
* Dedicated axios instance for the AI Assistant.
*
* Mirrors the request/response interceptor stack of the main SigNoz axios
* instance — most importantly `interceptorRejected`, which transparently
* rotates the access token via `/sessions/rotate` on a 401 and replays the
* original request. That's why we don't need any AI-specific 401 handling
* for REST calls: this instance inherits the same flow as the rest of the
* app for free.
*
* Only the SSE stream (`streamEvents`) still needs raw fetch since axios
* doesn't expose `ReadableStream` — that path keeps its own auth wrapper.
*/
export const AIAssistantInstance = axios.create({
baseURL: AI_BASE_URL,
});
AIAssistantInstance.interceptors.request.use(interceptorsRequestResponse);
AIAssistantInstance.interceptors.request.use(interceptorsRequestBasePath);
AIAssistantInstance.interceptors.response.use(
interceptorsResponse,
interceptorRejected,
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,46 +4,14 @@ import {
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import { ENVIRONMENT } from 'constants/env';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { ENVIRONMENT } from 'constants/env';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
export const setGeneratedAPIQueryKeyHeaderContext = <THeaders extends object>(
headers?: THeaders,
): void => {
generatedAPIQueryKeyHeaderContext = headers
? { ...(headers as Record<string, unknown>) }
: undefined;
};
const hashHeaderValue = (value: string): string => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
};
const mergeHeaderRecord = (
target: Record<string, unknown>,
source: unknown,
): Record<string, unknown> => {
if (!source || typeof source !== 'object') {
return target;
}
return Object.assign(target, source as Record<string, unknown>);
};
export const GeneratedAPIInstance = <T>(
config: AxiosRequestConfig,
): Promise<T> => {
@@ -58,59 +26,5 @@ generatedAPIAxiosInstance.interceptors.response.use(
interceptorRejected,
);
const getDefaultQueryKeyHeaders = (): Record<string, unknown> => {
const defaults = generatedAPIAxiosInstance.defaults
.headers as unknown as Record<string, unknown>;
const headers: Record<string, unknown> = {};
const methodKeys = new Set([
'common',
'delete',
'get',
'head',
'options',
'patch',
'post',
'put',
]);
mergeHeaderRecord(headers, defaults?.common);
mergeHeaderRecord(headers, defaults?.get);
for (const [key, value] of Object.entries(defaults ?? {})) {
if (!methodKeys.has(key)) {
headers[key] = value;
}
}
return headers;
};
export const getGeneratedAPIQueryKeyHeaders = <THeaders extends object>(
headers?: THeaders,
): [{ headers: Record<string, unknown> }] | [] => {
const mergedHeaders = {
...getDefaultQueryKeyHeaders(),
...generatedAPIQueryKeyHeaderContext,
...(headers as Record<string, unknown> | undefined),
};
const queryKeyHeaders = Object.fromEntries(
Object.entries(mergedHeaders)
.filter(([, value]) => value !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => {
if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
return [key, hashHeaderValue(value)];
}
return [key, value];
}),
);
return Object.keys(queryKeyHeaders).length
? [{ headers: queryKeyHeaders }]
: [];
};
export type ErrorType<Error> = AxiosError<Error>;
export type BodyType<BodyData> = BodyData;

View File

@@ -0,0 +1,33 @@
.error-state-container {
height: 240px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
border-radius: 3px;
.error-state-container-content {
display: flex;
flex-direction: column;
gap: 8px;
.error-state-text {
font-size: 14px;
font-weight: 500;
}
.error-state-additional-messages {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
.error-state-additional-text {
font-size: 12px;
font-weight: 400;
margin-left: 8px;
}
}
}
}

View File

@@ -0,0 +1,59 @@
import { Typography } from 'antd';
import APIError from '../../types/api/error';
import './Common.styles.scss';
interface ErrorStateComponentProps {
message?: string;
error?: APIError;
}
const defaultProps: Partial<ErrorStateComponentProps> = {
message: undefined,
error: undefined,
};
function ErrorStateComponent({
message,
error,
}: ErrorStateComponentProps): JSX.Element {
// Handle API Error object
if (error) {
const mainMessage = error.getErrorMessage();
const additionalErrors = error.getErrorDetails().error.errors || [];
return (
<div className="error-state-container">
<div className="error-state-container-content">
<Typography className="error-state-text">{mainMessage}</Typography>
{additionalErrors.length > 0 && (
<div className="error-state-additional-messages">
{additionalErrors.map((additionalError) => (
<Typography
key={`error-${additionalError.message}`}
className="error-state-additional-text"
>
{additionalError.message}
</Typography>
))}
</div>
)}
</div>
</div>
);
}
// Handle simple string message (backwards compatibility)
return (
<div className="error-state-container">
<div className="error-state-container-content">
<Typography className="error-state-text">{message}</Typography>
</div>
</div>
);
}
ErrorStateComponent.defaultProps = defaultProps;
export default ErrorStateComponent;

View File

@@ -0,0 +1,4 @@
.custom-date-picker {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,105 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { DatePicker } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import {
CustomTimeType,
LexicalContext,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import dayjs, { Dayjs } from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import './RangePickerModal.styles.scss';
interface RangePickerModalProps {
setCustomDTPickerVisible: Dispatch<SetStateAction<boolean>>;
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCustomDateHandler: (
dateTimeRange: DateTimeRangeType,
lexicalContext?: LexicalContext | undefined,
) => void;
selectedTime: string;
onTimeChange?: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
function RangePickerModal(props: RangePickerModalProps): JSX.Element {
const {
setCustomDTPickerVisible,
setIsOpen,
onCustomDateHandler,
selectedTime,
onTimeChange,
} = props;
const { RangePicker } = DatePicker;
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Using any type here because antd's DatePicker expects its own internal Dayjs type
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
const disabledDate = (current: any): boolean => {
const currentDay = dayjs(current);
return currentDay.isAfter(dayjs());
};
const onPopoverClose = (visible: boolean): void => {
if (!visible) {
setCustomDTPickerVisible(false);
}
setIsOpen(visible);
};
const onModalOkHandler = (date_time: any): void => {
if (date_time?.[1]) {
onPopoverClose(false);
}
onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER);
};
const { timezone } = useTimezone();
const rangeValue: [Dayjs, Dayjs] = useMemo(
() => [
dayjs(minTime / 1000_000).tz(timezone.value),
dayjs(maxTime / 1000_000).tz(timezone.value),
],
[maxTime, minTime, timezone.value],
);
return (
<div className="custom-date-picker">
<RangePicker
disabledDate={disabledDate}
allowClear
showTime
format={(date: Dayjs): string =>
date.tz(timezone.value).format(DATE_TIME_FORMATS.ISO_DATETIME)
}
onOk={onModalOkHandler}
data-1p-ignore
{...(selectedTime === 'custom' &&
!onTimeChange && {
value: rangeValue,
})}
// use default value if onTimeChange is provided
{...(selectedTime === 'custom' &&
onTimeChange && {
defaultValue: rangeValue,
})}
/>
</div>
);
}
RangePickerModal.defaultProps = {
onTimeChange: undefined,
};
export default RangePickerModal;

View File

@@ -0,0 +1,93 @@
.details-drawer {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
}
.ant-drawer-header {
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
.ant-drawer-header-title {
display: flex;
align-items: center;
.ant-drawer-close {
margin-inline-end: 0px;
padding: 0px;
padding-right: 16px;
border-right: 1px solid var(--l1-border);
}
.ant-drawer-title {
padding-left: 16px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.ant-drawer-body {
padding: 16px;
background: var(--l2-background);
&::-webkit-scrollbar {
width: 0.1rem;
}
}
.details-drawer-tabs {
margin-top: 32px;
.ant-tabs-tab {
display: flex;
align-items: center;
justify-content: center;
width: 114px;
height: 32px;
flex-shrink: 0;
padding: 7px 20px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0px;
}
.ant-btn:hover {
background: unset;
}
}
.ant-tabs-tab-active {
background: var(--l3-background);
}
.ant-tabs-tab + .ant-tabs-tab {
margin-left: 0px;
}
.ant-tabs-nav::before {
border-bottom: 0px;
}
.ant-tabs-ink-bar {
background: none;
}
}
}

View File

@@ -0,0 +1,57 @@
import { Dispatch, SetStateAction } from 'react';
import { Drawer, Tabs, TabsProps } from 'antd';
import cx from 'classnames';
import './DetailsDrawer.styles.scss';
interface IDetailsDrawerProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
title: string;
descriptiveContent: JSX.Element;
defaultActiveKey: string;
items: TabsProps['items'];
detailsDrawerClassName?: string;
tabBarExtraContent?: JSX.Element;
}
function DetailsDrawer(props: IDetailsDrawerProps): JSX.Element {
const {
open,
setOpen,
title,
descriptiveContent,
defaultActiveKey,
detailsDrawerClassName,
items,
tabBarExtraContent,
} = props;
return (
<Drawer
width="60%"
open={open}
afterOpenChange={setOpen}
mask={false}
title={title}
onClose={(): void => setOpen(false)}
className="details-drawer"
>
<div>{descriptiveContent}</div>
<Tabs
items={items}
addIcon
defaultActiveKey={defaultActiveKey}
animated
className={cx('details-drawer-tabs', detailsDrawerClassName)}
tabBarExtraContent={tabBarExtraContent}
/>
</Drawer>
);
}
DetailsDrawer.defaultProps = {
detailsDrawerClassName: '',
tabBarExtraContent: null,
};
export default DetailsDrawer;

View File

@@ -0,0 +1,143 @@
import { useState } from 'react';
import { Button } from 'antd';
import { withErrorBoundary } from './index';
/**
* Example component that can throw errors
*/
function ProblematicComponent(): JSX.Element {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('This is a test error from ProblematicComponent!');
}
return (
<div style={{ padding: '20px' }}>
<h3>Problematic Component</h3>
<p>This component can throw errors when the button is clicked.</p>
<Button type="primary" onClick={(): void => setShouldThrow(true)} danger>
Trigger Error
</Button>
</div>
);
}
/**
* Basic usage - wraps component with default error boundary
*/
export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent);
/**
* Usage with custom fallback component
*/
function CustomErrorFallback(): JSX.Element {
return (
<div
style={{ padding: '20px', border: '1px solid red', borderRadius: '4px' }}
>
<h4 style={{ color: 'red' }}>Custom Error Fallback</h4>
<p>Something went wrong in this specific component!</p>
<Button onClick={(): void => window.location.reload()}>Reload Page</Button>
</div>
);
}
export const SafeProblematicComponentWithCustomFallback = withErrorBoundary(
ProblematicComponent,
{
fallback: <CustomErrorFallback />,
},
);
/**
* Usage with custom error handler
*/
export const SafeProblematicComponentWithErrorHandler = withErrorBoundary(
ProblematicComponent,
{
onError: (error, errorInfo) => {
console.error('Custom error handler:', error);
console.error('Error info:', errorInfo);
// You could also send to analytics, logging service, etc.
},
sentryOptions: {
tags: {
section: 'dashboard',
priority: 'high',
},
level: 'error',
},
},
);
/**
* Example of wrapping an existing component from the codebase
*/
function ExistingComponent({
title,
data,
}: {
title: string;
data: any[];
}): JSX.Element {
// This could be any existing component that might throw errors
return (
<div>
<h4>{title}</h4>
<ul>
{data.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<li key={index}>{item.name}</li>
))}
</ul>
</div>
);
}
export const SafeExistingComponent = withErrorBoundary(ExistingComponent, {
sentryOptions: {
tags: {
component: 'ExistingComponent',
feature: 'data-display',
},
},
});
/**
* Usage examples in a container component
*/
export function ErrorBoundaryExamples(): JSX.Element {
const sampleData = [
{ name: 'Item 1' },
{ name: 'Item 2' },
{ name: 'Item 3' },
];
return (
<div style={{ padding: '20px' }}>
<h2>Error Boundary HOC Examples</h2>
<div style={{ marginBottom: '20px' }}>
<h3>1. Basic Usage</h3>
<SafeProblematicComponent />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>2. With Custom Fallback</h3>
<SafeProblematicComponentWithCustomFallback />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>3. With Custom Error Handler</h3>
<SafeProblematicComponentWithErrorHandler />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>4. Wrapped Existing Component</h3>
<SafeExistingComponent title="Sample Data" data={sampleData} />
</div>
</div>
);
}

View File

@@ -4,49 +4,6 @@
gap: 8px;
}
.header-ai-assistant-btn-container {
display: flex;
align-items: center;
gap: 4px;
}
.header-ai-assistant-btn__prefix {
display: inline-flex;
align-items: center;
gap: 6px;
}
.header-ai-assistant-btn__badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--bg-robin-500);
}
.header-ai-assistant-btn__pulse-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: header-ai-assistant-dot-pulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes header-ai-assistant-dot-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}
.share-modal-content,
.feedback-modal-container {
display: flex;

View File

@@ -1,17 +1,8 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot } from '@signozhq/icons';
import { Button, Tooltip } from '@signozhq/ui';
import { Popover } from 'antd';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import AnnouncementsModal from './AnnouncementsModal';
@@ -38,7 +29,6 @@ function HeaderRightSection({
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
@@ -77,44 +67,9 @@ function HeaderRightSection({
};
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const pendingUserInputCount: number = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
const showHeaderPendingBadge =
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
return (
<div className="header-right-section-container">
{isAIAssistantEnabled && !isDrawerOpen && (
<div className="header-ai-assistant-btn-container">
{showHeaderPendingBadge ? (
<span className="header-ai-assistant-btn__badge" aria-hidden>
<span className="header-ai-assistant-btn__pulse-dot">
<Dot size={36} />
</span>
</span>
) : null}
<Tooltip title="AI Assistant">
<Button
variant="ghost"
size="icon"
onClick={openAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
}
prefix={<AIAssistantIcon />}
/>
</Tooltip>
</div>
)}
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"
@@ -128,11 +83,12 @@ function HeaderRightSection({
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
variant="ghost"
size="icon"
prefix={<SquarePen size={14} />}
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
>
Feedback
</Button>
</Popover>
)}
@@ -149,9 +105,8 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
variant="ghost"
size="icon"
prefix={<Inbox size={14} />}
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
@@ -174,11 +129,12 @@ function HeaderRightSection({
onOpenChange={handleOpenShareURLModalChange}
>
<Button
variant="ghost"
size="icon"
prefix={<Globe size={14} />}
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
onClick={handleOpenShareURLModal}
/>
>
Share
</Button>
</Popover>
)}
</div>

View File

@@ -46,10 +46,6 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;

View File

@@ -0,0 +1,13 @@
.query-builder-search-wrapper {
margin-top: 10px;
border: 1px solid var(--l1-border);
border-bottom: none;
.ant-select-selector {
border: none !important;
input {
font-size: 12px;
}
}
}

View File

@@ -0,0 +1,79 @@
import { Dispatch, SetStateAction, useEffect } from 'react';
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import './QueryBuilderSearchWrapper.styles.scss';
function QueryBuilderSearchWrapper({
log,
filters,
contextQuery,
isEdit,
suffixIcon,
setFilters,
setContextQuery,
}: QueryBuilderSearchWraperProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
useEffect(() => {
setContextQuery(initialContextQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = (tagFilters: TagFilter): void => {
const tagFiltersLength = tagFilters.items.length;
if (
(!tagFiltersLength && (!filters || !filters.items.length)) ||
tagFiltersLength === filters?.items.length ||
!contextQuery
) {
return;
}
const nextQuery: Query = {
...contextQuery,
builder: {
...contextQuery.builder,
queryData: contextQuery.builder.queryData.map((item) => ({
...item,
filters: tagFilters,
})),
},
};
setFilters({ ...tagFilters });
setContextQuery({ ...nextQuery });
};
if (!contextQuery || !isEdit) {
return <></>;
}
return (
<QueryBuilderSearch
query={contextQuery?.builder.queryData[0]}
onChange={handleSearch}
className="query-builder-search-wrapper"
suffixIcon={suffixIcon}
/>
);
}
interface QueryBuilderSearchWraperProps {
log: ILog;
isEdit: boolean;
contextQuery: Query | undefined;
setContextQuery: Dispatch<SetStateAction<Query | undefined>>;
filters: TagFilter | null;
setFilters: Dispatch<SetStateAction<TagFilter | null>>;
suffixIcon?: React.ReactNode;
}
QueryBuilderSearchWrapper.defaultProps = {
suffixIcon: undefined,
};
export default QueryBuilderSearchWrapper;

View File

@@ -0,0 +1,3 @@
import { CSSProperties } from 'react';
export const rawLineStyle: CSSProperties = {};

View File

@@ -0,0 +1,8 @@
import { Button } from 'antd';
import styled from 'styled-components';
export const ButtonContainer = styled(Button)`
&&& {
padding-left: 0;
}
`;

View File

@@ -0,0 +1,13 @@
.custom-multiselect-dropdown {
.divider {
height: 1px;
background-color: #e8e8e8;
margin: 4px 0;
}
.all-option {
font-weight: 500;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
}

View File

@@ -0,0 +1,19 @@
.loading-panel-data {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-panel-data-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@@ -0,0 +1,17 @@
import { Typography } from 'antd';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import './PanelDataLoading.styles.scss';
export function PanelDataLoading(): JSX.Element {
return (
<div className="loading-panel-data">
<div className="loading-panel-data-content">
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
<Typography.Text>Fetching data...</Typography.Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { css, FlattenSimpleInterpolation } from 'styled-components';
const cssProperty = (key: any, value: any): FlattenSimpleInterpolation =>
key &&
value &&
css`
${key}: ${value};
`;
interface IFlexProps {
flexDirection?: string; // Need to replace this with exact css props. Not able to find any :(
flex?: number | string;
}
export const Flex = ({
flexDirection,
flex,
}: IFlexProps): FlattenSimpleInterpolation => css`
${cssProperty('flex-direction', flexDirection)}
${cssProperty('flex', flex)}
`;
interface IDisplayProps {
display?: string;
}
export const Display = ({
display,
}: IDisplayProps): FlattenSimpleInterpolation => css`
${cssProperty('display', display)}
`;
interface ISpacingProps {
margin?: string;
padding?: string;
}
export const Spacing = ({
margin,
padding,
}: ISpacingProps): FlattenSimpleInterpolation => css`
${cssProperty('margin', margin)}
${cssProperty('padding', padding)}
`;

View File

@@ -0,0 +1,5 @@
export type TabLabelProps = {
isDisabled: boolean;
label: string;
tooltipText?: string;
};

View File

@@ -0,0 +1,29 @@
import { memo } from 'react';
import { Tooltip } from 'antd';
import { TabLabelProps } from './TabLabel.interfaces';
function TabLabel({
label,
isDisabled,
tooltipText,
}: TabLabelProps): JSX.Element {
const currentLabel = <span data-testid={`${label}`}>{label}</span>;
if (isDisabled) {
return (
<Tooltip
trigger="hover"
autoAdjustOverflow
placement="top"
title={tooltipText}
>
{currentLabel}
</Tooltip>
);
}
return currentLabel;
}
export default memo(TabLabel);

View File

@@ -0,0 +1,5 @@
.tab-title {
display: flex;
gap: 4px;
align-items: center;
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { Radio } from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import { History, Table } from 'lucide-react';
import { ALERT_TABS } from '../constants';
import './Tabs.styles.scss';
export function Tabs(): JSX.Element {
const [selectedTab, setSelectedTab] = useState('overview');
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedTab(e.target.value);
};
return (
<Radio.Group className="tabs" onChange={handleTabChange} value={selectedTab}>
<Radio.Button
className={
selectedTab === ALERT_TABS.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={ALERT_TABS.OVERVIEW}
>
<div className="tab-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedTab === ALERT_TABS.HISTORY ? 'selected_view tab' : 'tab'}
value={ALERT_TABS.HISTORY}
>
<div className="tab-title">
<History size={14} />
History
</div>
</Radio.Button>
</Radio.Group>
);
}

View File

@@ -0,0 +1,18 @@
@mixin flex-center {
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs-and-filters {
@include flex-center;
margin-top: 1rem;
margin-bottom: 1rem;
.filters {
@include flex-center;
gap: 16px;
.reset-button {
@include flex-center;
}
}
}

View File

@@ -0,0 +1,16 @@
import { Filters } from 'components/AlertDetailsFilters/Filters';
import { Tabs } from './Tabs/Tabs';
import './TabsAndFilters.styles.scss';
function TabsAndFilters(): JSX.Element {
return (
<div className="tabs-and-filters">
<Tabs />
<Filters />
</div>
);
}
export default TabsAndFilters;

View File

@@ -0,0 +1,5 @@
export const ALERT_TABS = {
OVERVIEW: 'OVERVIEW',
HISTORY: 'HISTORY',
ACTIVITY: 'ACTIVITY',
} as const;

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
import tableStyles from './TanStackTable.module.scss';
import styles from './TanStackTableSkeleton.module.scss';
type TanStackTableSkeletonProps<TData> = {
columns: TableColumnDef<TData>[];
rowCount: number;
isDarkMode: boolean;
columnSizing?: ColumnSizingState;
};
export function TanStackTableSkeleton<TData>({
columns,
rowCount,
isDarkMode,
columnSizing,
}: TanStackTableSkeletonProps<TData>): JSX.Element {
const rows = useMemo(
() => Array.from({ length: rowCount }, (_, i) => i),
[rowCount],
);
return (
<table className={tableStyles.tanStackTable}>
<colgroup>
{columns.map((column, index) => (
<col
key={column.id}
style={getColumnWidthStyle(
column,
columnSizing?.[column.id],
index === columns.length - 1,
)}
/>
))}
</colgroup>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
className={tableStyles.tableHeaderCell}
data-dark-mode={isDarkMode}
>
{typeof column.header === 'function' ? (
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
) : (
column.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((rowIndex) => (
<tr key={rowIndex} className={tableStyles.tableRow}>
{columns.map((column) => (
<td key={column.id} className={tableStyles.tableCell}>
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
interface TextContainerProps {
noButtonMargin?: boolean;
}
export const TextContainer = styled.div<TextContainerProps>`
display: flex;
> button {
margin-left: ${({ noButtonMargin }): string =>
noButtonMargin ? '0' : '0.5rem'}
`;

View File

@@ -0,0 +1,42 @@
import { ReactChild } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Space, Typography } from 'antd';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import { Container, LeftContainer, Logo } from './styles';
const { Title } = Typography;
function WelcomeLeftContainer({
version,
children,
}: WelcomeLeftContainerProps): JSX.Element {
const { t } = useTranslation();
return (
<Container>
<LeftContainer direction="vertical">
<Space align="center">
<Logo src={signozBrandLogoUrl} alt="logo" />
<Title style={{ fontSize: '46px', margin: 0 }}>SigNoz</Title>
</Space>
<Typography>{t('monitor_signup')}</Typography>
<Card
style={{ width: 'max-content' }}
bodyStyle={{ padding: '1px 8px', width: '100%' }}
>
SigNoz {version}
</Card>
</LeftContainer>
{children}
</Container>
);
}
interface WelcomeLeftContainerProps {
version: string;
children: ReactChild;
}
export default WelcomeLeftContainer;

View File

@@ -0,0 +1,23 @@
import { Space } from 'antd';
import styled from 'styled-components';
export const LeftContainer = styled(Space)`
flex: 1;
`;
export const Logo = styled.img`
width: 60px;
`;
export const Container = styled.div`
&&& {
display: flex;
justify-content: center;
gap: 16px;
align-items: center;
min-height: 100vh;
max-width: 1024px;
margin: 0 auto;
}
`;

View File

@@ -9,7 +9,5 @@ export enum FeatureKeys {
ANOMALY_DETECTION = 'anomaly_detection',
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
/** When active, AI assistant UI, routes, and integrations are available. */
AI_ASSISTANT_ENABLED = 'ai_assistant_enabled',
USE_JSON_BODY = 'use_json_body',
}

View File

@@ -87,8 +87,6 @@ const ROUTES = {
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
AI_ASSISTANT: '/ai-assistant/:conversationId',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
} as const;

View File

@@ -1,101 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import { VariantContext } from './VariantContext';
export default function AIAssistantDrawer(): JSX.Element {
const history = useHistory();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNewConversation = useCallback(() => {
startNewConversation();
}, [startNewConversation]);
return (
<Drawer
open={isDrawerOpen}
onClose={closeDrawer}
placement="right"
width={420}
// Suppress default close button — we render our own header
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
</div>
<div>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNewConversation}
aria-label="New conversation"
>
<Plus size={16} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={16} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close drawer"
>
<X size={16} />
</Button>
</Tooltip>
</div>
</div>
}
>
<VariantContext.Provider value="panel">
{activeConversationId ? (
<ConversationView conversationId={activeConversationId} />
) : null}
</VariantContext.Provider>
</Drawer>
);
}

View File

@@ -1,96 +0,0 @@
$radius: 4px;
.backdrop {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
animation: backdropIn 0.15s ease;
}
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
display: flex;
flex-direction: column;
width: 70vw;
height: 80vh;
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: $radius;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
animation: modalIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.shortcut {
font-size: 10px;
font-family: var(--font-mono, monospace);
font-weight: 500;
color: var(--l3-foreground, #6b7280);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 1px 5px;
letter-spacing: 0;
line-height: 1.6;
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.toggleBtnActive {
background: var(--l2-background) !important;
color: var(--primary, var(--bg-robin-500)) !important;
}

View File

@@ -1,210 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
import AIAssistantIcon from './components/AIAssistantIcon';
import HistorySidebar from './components/HistorySidebar';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import { VariantContext } from './VariantContext';
import styles from './AIAssistantModal.module.scss';
/**
* Global floating modal for the AI Assistant.
*
* - Triggered by Cmd+J (Mac) / Ctrl+J (Windows/Linux)
* - Escape or the × button fully closes it
* - The (minimize) button collapses to the side panel
* - Mounted once in AppLayout; always in the DOM, conditionally visible
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const openModal = useAIAssistantStore((s) => s.openModal);
const closeModal = useAIAssistantStore((s) => s.closeModal);
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
// Cmd+J (Mac) / Ctrl+J (Win/Linux) — toggle modal. Opening
// always starts a brand-new conversation; resuming earlier
// threads is done via the in-modal history sidebar.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
// Don't intercept Cmd+J inside input/textarea — those are for the user
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return;
}
e.preventDefault();
if (isOpen) {
closeModal();
} else {
startNewConversation();
setShowHistory(false);
openModal();
}
return;
}
// Escape — close modal
if (e.key === 'Escape' && isOpen) {
closeModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
// ── Handlers ────────────────────────────────────────────────────────────────
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeModal();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
const handleMinimize = useCallback(() => {
minimizeModal();
setShowHistory(false);
}, [minimizeModal]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close when clicking the backdrop itself, not the modal card
if (e.target === e.currentTarget) {
closeModal();
}
},
[closeModal],
);
// ── Render ──────────────────────────────────────────────────────────────────
if (!isOpen) {
return null;
}
return createPortal(
<VariantContext.Provider value="modal">
<div
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<AIAssistantIcon size={16} />
<span>AI Assistant</span>
<kbd className={styles.shortcut}>J</kbd>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
className={showHistory ? styles.toggleBtnActive : ''}
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
onClick={handleMinimize}
aria-label="Minimize to side panel"
>
<Minus size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
onClick={closeModal}
aria-label="Close"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{/* Body */}
<div className={styles.body}>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</div>
</div>
</VariantContext.Provider>,
document.body,
);
}

View File

@@ -1,62 +0,0 @@
$radius: 4px;
.panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
border-left: 1px solid var(--l1-border);
background: var(--l1-background);
overflow: hidden;
position: relative;
}
.resizeHandle {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 10;
&::after {
content: '';
position: absolute;
top: 0;
left: 1px;
width: 2px;
height: 100%;
background: transparent;
transition: background 0.15s ease;
}
&:hover::after {
background: var(--primary-400, #7c3aed);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}

View File

@@ -1,163 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, X } from '@signozhq/icons';
import AIAssistantIcon from './components/AIAssistantIcon';
import HistorySidebar from './components/HistorySidebar';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import { VariantContext } from './VariantContext';
import styles from './AIAssistantPanel.module.scss';
export default function AIAssistantPanel(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
// ── Resize logic ──────────────────────────────────────────────────────────
const [panelWidth, setPanelWidth] = useState(380);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartWidth.current = panelWidth;
const onMouseMove = (ev: MouseEvent): void => {
// Panel is on the right; dragging left (lower clientX) increases width
const delta = dragStartX.current - ev.clientX;
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
setPanelWidth(next);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth],
);
if (!isOpen || isFullScreenPage) {
return null;
}
return (
<VariantContext.Provider value="panel">
<div className={styles.panel} style={{ width: panelWidth }}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<AIAssistantIcon size={18} />
<span>AI Assistant</span>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close panel"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</VariantContext.Provider>
);
}

View File

@@ -1,32 +0,0 @@
.trigger {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary, var(--bg-robin-500));
color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transition:
transform 0.15s,
box-shadow 0.15s;
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
}
&:active {
transform: scale(0.96);
}
}

View File

@@ -1,44 +0,0 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button, Tooltip } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import {
openAIAssistant,
useAIAssistantStore,
} from './store/useAIAssistantStore';
import styles from './AIAssistantTrigger.module.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<Bot size={20} />
</Button>
</Tooltip>
);
}

View File

@@ -1,53 +0,0 @@
.conversation {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--l3-foreground, var(--l2-foreground));
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.inputWrapper {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid var(--l1-border);
&.compact {
padding: 8px;
}
}
.disclaimer {
flex-shrink: 0;
padding: 8px 16px;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
color: var(--l3-foreground, var(--l2-foreground));
text-align: center;
&.compact {
padding: 8px 12px;
}
}

View File

@@ -1,166 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
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';
import { useVariant } from './VariantContext';
import styles from './ConversationView.module.scss';
interface ConversationViewProps {
conversationId: string;
}
export default function ConversationView({
conversationId,
}: ConversationViewProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const location = useLocation();
const conversation = useAIAssistantStore(
(s) => s.conversations[conversationId],
);
const isStreamingHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
const pendingApprovalHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarificationHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
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,
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => {
void sendMessage(text, attachments, contexts);
},
[sendMessage],
);
const handleCancel = useCallback(() => {
cancelStream(conversationId);
}, [cancelStream, conversationId]);
// One-shot push of text into the chat input, used by the per-bubble
// "Edit and resend" action. The wrapper object gives every push a
// fresh identity so consecutive edits of the same text still trigger
// ChatInput's consume effect.
const [pendingDraft, setPendingDraft] = useState<{ text: string } | null>(
null,
);
const handleEditMessage = useCallback((text: string): void => {
setPendingDraft({ text });
}, []);
const handleDraftConsumed = useCallback((): void => {
setPendingDraft(null);
}, []);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
const inputDisabled =
isStreamingHere ||
isLoadingThread ||
Boolean(pendingApprovalHere) ||
Boolean(pendingClarificationHere);
const inputWrapperClass = cx(styles.inputWrapper, {
[styles.compact]: isCompact,
});
const disclaimerClass = cx(styles.disclaimer, {
[styles.compact]: isCompact,
});
if (isLoadingThread && messages.length === 0) {
return (
<div className={styles.conversation}>
<ConversationSkeleton />
<div className={inputWrapperClass}>
<ChatInput
onSend={handleSend}
disabled
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>
</div>
</div>
);
}
return (
<div className={styles.conversation}>
<VirtualizedMessages
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onEditMessage={handleEditMessage}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">
SigNoz AI can make mistakes. Please double-check responses.
</div>
)}
<div className={inputWrapperClass}>
<ChatInput
onSend={handleSend}
onCancel={handleCancel}
disabled={inputDisabled}
isStreaming={isStreamingHere}
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
pendingDraft={pendingDraft}
onDraftConsumed={handleDraftConsumed}
/>
</div>
</div>
);
}

View File

@@ -1,649 +0,0 @@
# Page-Aware AI Action System — Technical Design
**Status:** Draft
**Author:** AI Assistant
**Created:** 2026-03-31
**Scope:** Frontend — AI Assistant integration with page-specific actions
---
## 1. Overview
The Page-Aware AI Action System extends the AI Assistant so that it can understand what page the user is currently on, read the page's live state (active filters, time range, selected entities, etc.), and execute actions available on that page — all through a natural-language conversation.
### Goals
- Let users query, filter, and navigate each SigNoz page by talking to the AI
- Let users create and modify entities (dashboards, alerts, saved views) via the AI
- Keep page-specific wiring isolated and co-located with each page — not inside the AI core
- Zero-friction adoption: adding AI support to a new page is a single `usePageActions(...)` call
- Prevent the AI from silently mutating state — every action requires explicit user confirmation
### Non-Goals
- Backend AI model training or fine-tuning
- Real-time data streaming inside the AI chat (charts already handle that via existing blocks)
- Cross-session memory of user preferences (deferred to a future persistent-context system)
---
## 2. Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Active Page (e.g. LogsExplorer) │ │
│ │ │ │
│ │ usePageActions('logs-explorer', [...actions]) │ │
│ │ │ registers on mount │ │
│ │ │ unregisters on unmount │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ PageActionRegistry│ ◄── singleton │ │
│ │ │ Map<id, Action> │ │ │
│ │ └────────┬─────────┘ │ │
│ └───────────┼─────────────────────────────────────┘ │
│ │ getAll() + context snapshot │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ AI Assistant (Drawer / Full-page) │ │
│ │ │ │
│ │ sendMessage() │ │
│ │ ├── builds [PAGE_CONTEXT] block from registry │ │
│ │ ├── appends user text │ │
│ │ └── sends to API ──────────────────────────────────► │ │
│ │ AI Backend / Mock │ │
│ │ ◄── streaming response │ │
│ │ │ │
│ │ MessageBubble │ │
│ │ └── RichCodeBlock detects ```ai-action │ │
│ │ └── ActionBlock │ │
│ │ ├── renders description + param preview │ │
│ │ ├── Accept → PageActionRegistry.execute() │ │
│ │ └── Reject → no-op │ │
│ └───────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## 3. Data Model
### 3.1 `PageAction<TParams>`
The descriptor for a single action a page exposes to the AI.
```typescript
interface PageAction<TParams = Record<string, unknown>> {
/**
* Stable identifier, dot-namespaced by page.
* e.g. "logs.runQuery", "dashboard.createPanel", "alert.save"
*/
id: string;
/**
* Natural-language description sent verbatim in the page context block.
* The AI uses this to decide which action to invoke.
*/
description: string;
/**
* JSON Schema (draft-07) describing the parameters this action accepts.
* Sent to the AI so it can generate structurally valid calls.
*/
parameters: JSONSchemaObject;
/**
* Performs the action. Resolves with a result the AI can narrate back to
* the user. Rejects if the action cannot be completed.
*/
execute: (params: TParams) => Promise<ActionResult>;
/**
* Optional snapshot of the current page state.
* Called at message-send time so the AI has fresh context.
* Return value is JSON-serialised into the [PAGE_CONTEXT] block.
*/
getContext?: () => unknown;
}
interface ActionResult {
/** Short human-readable outcome: "Query updated with 2 new filters." */
summary: string;
/** Optional structured data the AI block can display (e.g. a new URL) */
data?: Record<string, unknown>;
}
type JSONSchemaObject = {
type: 'object';
properties: Record<string, JSONSchemaProperty>;
required?: string[];
};
```
### 3.2 `PageActionDescriptor`
A lightweight, serialisable version of `PageAction` — safe to include in the API payload (no function references).
```typescript
interface PageActionDescriptor {
id: string;
description: string;
parameters: JSONSchemaObject;
context?: unknown; // snapshot from getContext()
}
```
### 3.3 `AIActionBlock`
The JSON payload the AI emits inside an ` ```ai-action ``` ` fenced block when it wants to invoke an action.
```typescript
interface AIActionBlock {
/** Must match a registered PageAction.id */
actionId: string;
/**
* One-sentence explanation of what the action will do.
* Displayed in the confirmation card.
*/
description: string;
/**
* Parameters the AI chose for this action.
* Validated against the action's JSON Schema before execute() is called.
*/
parameters: Record<string, unknown>;
}
```
---
## 4. PageActionRegistry
A module-level singleton (like `BlockRegistry`). Keeps a flat `Map<id, PageAction>` so look-up is O(1). Supports batch register/unregister keyed by a `pageId` so a page can remove all its actions at once on unmount.
```
src/container/AIAssistant/pageActions/PageActionRegistry.ts
```
### Interface
```typescript
const PageActionRegistry = {
/** Register a set of actions under a page scope key. */
register(pageId: string, actions: PageAction[]): void;
/** Remove all actions registered under a page scope key. */
unregister(pageId: string): void;
/** Look up a single action by its dot-namespaced id. */
get(actionId: string): PageAction | undefined;
/**
* Return serialisable descriptors for all currently registered actions,
* with context snapshots already collected.
*/
snapshot(): PageActionDescriptor[];
};
```
### Internal structure
```typescript
// pageId → action[] (for clean unregister)
const _byPage = new Map<string, PageAction[]>();
// actionId → action (for O(1) lookup at execute time)
const _byId = new Map<string, PageAction>();
```
---
## 5. `usePageActions` Hook
Pages call this hook to register their actions declaratively. React lifecycle handles cleanup.
```
src/container/AIAssistant/pageActions/usePageActions.ts
```
```typescript
function usePageActions(pageId: string, actions: PageAction[]): void {
useEffect(() => {
PageActionRegistry.register(pageId, actions);
return () => PageActionRegistry.unregister(pageId);
// Re-register if action definitions change (e.g. new callbacks after query update)
}, [pageId, actions]);
}
```
**Important:** action factories (see §8) memoize with `useMemo` so that the `actions` array reference is stable — preventing unnecessary re-registrations.
---
## 6. Context Injection in `sendMessage`
Before every outgoing message, the AI store reads the registry and prepends a machine-readable context block to the API payload content. This block is **never stored in the conversation** (not visible in the message list) — it exists only in the network payload.
```
[PAGE_CONTEXT]
page: logs-explorer
actions:
- id: logs.runQuery
description: "Run the current log query with updated filters or time range"
params: { filters: TagFilter[], timeRange?: string }
- id: logs.saveView
description: "Save the current query as a named view"
params: { name: string }
state:
filters: [{ key: "level", op: "=", value: "error" }]
timeRange: "Last 15 minutes"
panelType: "list"
[/PAGE_CONTEXT]
{user's message}
```
### Implementation in `useAIAssistantStore.sendMessage`
```typescript
// Build context prefix from registry
function buildContextPrefix(): string {
const descriptors = PageActionRegistry.snapshot();
if (descriptors.length === 0) return '';
const actionLines = descriptors.map(a =>
` - id: ${a.id}\n description: "${a.description}"\n params: ${JSON.stringify(a.parameters.properties)}`
).join('\n');
const contextLines = descriptors
.filter(a => a.context !== undefined)
.map(a => ` ${a.id}: ${JSON.stringify(a.context)}`)
.join('\n');
return [
'[PAGE_CONTEXT]',
'actions:',
actionLines,
contextLines ? 'state:' : '',
contextLines,
'[/PAGE_CONTEXT]',
'',
].filter(Boolean).join('\n');
}
// In sendMessage, when building the API payload:
const payload = {
conversationId: activeConversationId,
messages: history.map((m, i) => {
const content = (i === history.length - 1 && m.role === 'user')
? buildContextPrefix() + m.content
: m.content;
return { role: m.role, content };
}),
};
```
The displayed message in the UI always shows only `m.content` (the user's raw text). The context prefix only exists in the wire payload.
---
## 7. `ActionBlock` Component
Registered as `BlockRegistry.register('action', ActionBlock)`.
```
src/container/AIAssistant/components/blocks/ActionBlock.tsx
```
### Render states
```
┌─────────────────────────────────────────────────────┐
│ ⚡ Suggested Action │
│ │
│ "Filter logs for ERROR level from payment-service" │
│ │
│ Parameters: │
│ • level = ERROR │
│ • service.name = payment-service │
│ │
│ [ Apply ] [ Dismiss ] │
└─────────────────────────────────────────────────────┘
── After Apply ──
┌─────────────────────────────────────────────────────┐
│ ✓ Applied: "Filter logs for ERROR level from │
│ payment-service" │
└─────────────────────────────────────────────────────┘
── After error ──
┌─────────────────────────────────────────────────────┐
│ ✗ Failed: "Action 'logs.runQuery' is not │
│ available on the current page." │
└─────────────────────────────────────────────────────┘
```
### Execution flow
1. Parse `AIActionBlock` JSON from the fenced block content
2. Validate `parameters` against the action's JSON Schema (fail fast with a clear error)
3. Look up `PageActionRegistry.get(actionId)` — if missing, show "not available" state
4. On Accept: call `action.execute(parameters)`, show loading spinner
5. On success: show summary from `ActionResult.summary`, call `markBlockAnswered(messageId, 'applied')`
6. On failure: show error, allow retry
7. On Dismiss: call `markBlockAnswered(messageId, 'dismissed')`
Like `ConfirmBlock` and `InteractiveQuestion`, `ActionBlock` uses `MessageContext` to get `messageId` and `answeredBlocks` from the store to persist its answered state across remounts.
---
## 8. Page Action Factories
Each page co-locates its action definitions in an `aiActions.ts` file. Factories are functions that close over the page's live state and handlers, so the `execute` callbacks always operate on current data.
### Example: `src/pages/LogsExplorer/aiActions.ts`
```typescript
export function logsRunQueryAction(deps: {
handleRunQuery: () => void;
updateQueryFilters: (filters: TagFilterItem[]) => void;
currentQuery: Query;
globalTime: GlobalReducer;
}): PageAction {
return {
id: 'logs.runQuery',
description: 'Update the active log filters and run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list. Each item has key, op, value.',
items: {
type: 'object',
properties: {
key: { type: 'string' },
op: { type: 'string', enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'] },
value: { type: 'string' },
},
required: ['key', 'op', 'value'],
},
},
},
},
execute: async ({ filters }) => {
deps.updateQueryFilters(filters as TagFilterItem[]);
deps.handleRunQuery();
return { summary: `Query updated with ${filters.length} filter(s) and re-run.` };
},
getContext: () => ({
filters: deps.currentQuery.builder.queryData[0]?.filters?.items ?? [],
timeRange: deps.globalTime.selectedTime,
panelType: 'list',
}),
};
}
export function logsSaveViewAction(deps: {
saveView: (name: string) => Promise<void>;
}): PageAction {
return {
id: 'logs.saveView',
description: 'Save the current log query as a named view',
parameters: {
type: 'object',
properties: { name: { type: 'string', description: 'View name' } },
required: ['name'],
},
execute: async ({ name }) => {
await deps.saveView(name as string);
return { summary: `View "${name}" saved.` };
},
};
}
```
### Usage in the page component
```typescript
// src/pages/LogsExplorer/index.tsx
const { handleRunQuery, updateQueryFilters, currentQuery } = useQueryBuilder();
const globalTime = useSelector((s) => s.globalTime);
const actions = useMemo(
() => [
logsRunQueryAction({ handleRunQuery, updateQueryFilters, currentQuery, globalTime }),
logsSaveViewAction({ saveView }),
logsExportToDashboardAction({ exportToDashboard }),
],
[handleRunQuery, updateQueryFilters, currentQuery, globalTime, saveView, exportToDashboard],
);
usePageActions('logs-explorer', actions);
```
---
## 9. Action Catalogue (Initial Scope)
### 9.1 Logs Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `logs.runQuery` | Update filters and run the log query | `filters: TagFilterItem[]` |
| `logs.addFilter` | Append a single filter to the existing set | `key, op, value` |
| `logs.changeView` | Switch between list / timeseries / table | `view: 'list' \| 'timeseries' \| 'table'` |
| `logs.saveView` | Save current query as a named view | `name: string` |
| `logs.exportToDashboard` | Add current query as a panel to a dashboard | `dashboardId?: string, panelTitle?: string` |
### 9.2 Traces Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `traces.runQuery` | Update filters and run the trace query | `filters: TagFilterItem[]` |
| `traces.addFilter` | Append a single filter | `key, op, value` |
| `traces.changeView` | Switch between list / trace / timeseries / table | `view: string` |
| `traces.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
### 9.3 Dashboards List
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `dashboards.create` | Create a new blank dashboard | `title: string, description?: string` |
| `dashboards.search` | Filter the dashboard list | `query: string` |
| `dashboards.duplicate` | Duplicate an existing dashboard | `dashboardId: string, newTitle?: string` |
| `dashboards.delete` | Delete a dashboard (requires confirmation) | `dashboardId: string` |
### 9.4 Dashboard Detail
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `dashboard.createPanel` | Add a new panel to the current dashboard | `title: string, queryType: 'logs'\|'metrics'\|'traces'` |
| `dashboard.rename` | Rename the current dashboard | `title: string` |
| `dashboard.deletePanel` | Remove a panel | `panelId: string` |
| `dashboard.addVariable` | Add a dashboard-level variable | `name: string, type: string, defaultValue?: string` |
### 9.5 Alerts
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `alerts.navigateCreate` | Navigate to the Create Alert page | `alertType?: 'metrics'\|'logs'\|'traces'` |
| `alerts.disable` | Disable an existing alert rule | `alertId: string` |
| `alerts.enable` | Enable an existing alert rule | `alertId: string` |
| `alerts.delete` | Delete an alert rule | `alertId: string` |
### 9.6 Create Alert
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `alert.setCondition` | Set the alert threshold condition | `op: string, threshold: number` |
| `alert.test` | Test the alert rule against live data | — |
| `alert.save` | Save the alert rule | `name: string, severity?: string` |
### 9.7 Metrics Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `metrics.runQuery` | Run a metric query | `metric: string, aggregation?: string` |
| `metrics.saveView` | Save current query as a view | `name: string` |
| `metrics.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
---
## 10. Context Block Schema (Wire Format)
The `[PAGE_CONTEXT]` block is a freeform text section prepended to the API message content. For the real backend, this should migrate to a structured `system` role message or a dedicated field in the request body. For the initial implementation, embedding it in the user message content is sufficient and works with any LLM API.
```
[PAGE_CONTEXT]
page: <pageId>
actions:
- id: <actionId>
description: "<description>"
params: <JSON Schema properties summary>
...
state:
<actionId>: <JSON context snapshot>
...
[/PAGE_CONTEXT]
```
---
## 11. Parameter Validation
Before `execute()` is called, parameters are validated client-side using the JSON Schema stored on the `PageAction`. This catches cases where the AI generates structurally wrong parameters.
```typescript
function validateParams(schema: JSONSchemaObject, params: unknown): string | null {
// Minimal validation: check required fields are present and types match
// Full implementation can use ajv or a lightweight equivalent
for (const key of schema.required ?? []) {
if (!(params as Record<string, unknown>)[key]) {
return `Missing required parameter: "${key}"`;
}
}
return null; // valid
}
```
If validation fails, `ActionBlock` shows the error inline and does not call `execute`.
---
## 12. Answered State Persistence
`ActionBlock` follows the same pattern as `ConfirmBlock` and `InteractiveQuestion`:
- Uses `MessageContext` to read `messageId`
- Reads `answeredBlocks[messageId]` from the Zustand store
- On Accept: calls `markBlockAnswered(messageId, 'applied')`
- On Dismiss: calls `markBlockAnswered(messageId, 'dismissed')`
- On Error: calls `markBlockAnswered(messageId, 'error:<message>')`
This ensures re-renders and re-mounts do not reset the block to its initial state.
---
## 13. Error Handling
| Failure scenario | Behaviour |
|-----------------|-----------|
| `actionId` not in registry (page navigated away) | Block shows: "This action is no longer available — navigate back to \<page\> and try again." |
| Parameter validation fails | Block shows the validation error inline; does not call `execute` |
| `execute()` throws | Block shows the error message; offers a Retry button |
| AI emits malformed JSON in the block | `RichCodeBlock` falls back to rendering the raw fenced block as a code block |
| User navigates away mid-execution | `execute()` promise resolves/rejects normally; result is stored in `answeredBlocks` |
---
## 14. Permissions
Many page actions map to protected operations (e.g., creating a dashboard, deleting an alert). Each action factory should check the relevant permission before registering — if the user doesn't have permission, the action is simply not registered and will not appear in the context block.
```typescript
const canCreateDashboard = useComponentPermission(['create_new_dashboards']);
const actions = useMemo(() => [
...(canCreateDashboard ? [dashboardCreateAction({ ... })] : []),
// ...
], [canCreateDashboard, ...]);
usePageActions('dashboard-list', actions);
```
This way the AI never suggests actions the user cannot perform.
---
## 15. Implementation Plan
### Phase 1 — Infrastructure (no page integrations yet)
1. `src/container/AIAssistant/pageActions/types.ts`
2. `src/container/AIAssistant/pageActions/PageActionRegistry.ts`
3. `src/container/AIAssistant/pageActions/usePageActions.ts`
4. `ActionBlock.tsx` + `ActionBlock.scss`
5. Register `'action'` in `blocks/index.ts`
6. Context injection in `useAIAssistantStore.sendMessage`
7. Mock API support for `[PAGE_CONTEXT]` → responds with `ai-action` block
### Phase 2 — Logs Explorer integration
8. `src/pages/LogsExplorer/aiActions.ts` (factories for `logs.*` actions)
9. Wire `usePageActions` into `LogsExplorer/index.tsx`
### Phase 3 — Traces, Dashboards, Alerts
10. `src/pages/TracesExplorer/aiActions.ts`
11. `src/pages/DashboardsListPage/aiActions.ts`
12. `src/pages/DashboardPage/aiActions.ts`
13. `src/pages/AlertList/aiActions.ts`
14. `src/pages/CreateAlert/aiActions.ts`
### Phase 4 — Backend handoff
15. Move `[PAGE_CONTEXT]` from content-embedded text to a dedicated `pageContext` field in the API request body
16. Replace mock responses with real AI backend calls
---
## 16. Open Questions
| # | Question | Impact |
|---|----------|--------|
| 1 | Should `ActionBlock` require a single user confirmation, or show a diff-style preview of what will change? | UX complexity |
| 2 | How should multi-step actions work? (e.g. "create dashboard then add three panels") — queue them or chain them? | Architecture |
| 3 | Should the registry support a global `getContext()` for page-agnostic state (user, org, time range)? | Context completeness |
| 4 | What is the max context block size before it degrades AI quality? | Prompt engineering |
| 5 | Should failed actions add a retry message back into the conversation, or stay silent? | UX |
| 6 | Can two pages be active simultaneously (e.g. drawer open over dashboard)? How do we prioritise which actions are "active"? | Edge case |
---
## 17. Relation to Existing AI Architecture
```
BlockRegistry PageActionRegistry
│ │
│ render blocks │ register/unregister actions
│ (ai-chart, ai- │ (logs.runQuery, dashboard.create...)
│ question, ai- │
│ confirm, ...) │
└──────────┬────────────┘
MessageBubble / StreamingMessage
RichCodeBlock (routes to BlockRegistry)
ActionBlock ←── new: reads PageActionRegistry to execute
```
The `PageActionRegistry` is a parallel singleton to `BlockRegistry`. `BlockRegistry` maps `fenced-block-type → render component`. `PageActionRegistry` maps `action-id → execute function`. `ActionBlock` bridges the two: it is a registered *block* (render side) that calls into the *action* registry (execution side).

View File

@@ -1,911 +0,0 @@
# AI Assistant — Technical Design Document
**Status:** In Progress
**Last Updated:** 2026-04-01
**Scope:** Frontend AI Assistant subsystem — UI, state management, API integration, page action system
---
## Table of Contents
1. [Overview](#1-overview)
2. [Architecture Diagram](#2-architecture-diagram)
3. [User Flows](#3-user-flows)
4. [UI Modes and Transitions](#4-ui-modes-and-transitions)
5. [Control Flow: UI → API → UI](#5-control-flow-ui--api--ui)
6. [SSE Response Handling](#6-sse-response-handling)
7. [Page Action System](#7-page-action-system)
8. [Block Rendering System](#8-block-rendering-system)
9. [State Management](#9-state-management)
10. [Page-Specific Actions](#10-page-specific-actions)
11. [Voice Input](#11-voice-input)
12. [File Attachments](#12-file-attachments)
13. [Development Mode (Mock)](#13-development-mode-mock)
14. [Adding a New Page's Actions](#14-adding-a-new-pages-actions)
15. [Adding a New Block Type](#15-adding-a-new-block-type)
16. [Data Contracts](#16-data-contracts)
17. [Key Design Decisions](#17-key-design-decisions)
---
## 1. Overview
The AI Assistant is an embedded chat interface inside SigNoz that understands the current page context and can execute actions on behalf of the user (e.g., filter logs, update queries, navigate views). It communicates with a backend AI service via Server-Sent Events (SSE) and renders structured responses as rich interactive blocks alongside plain markdown.
**Key goals:**
- **Context-aware:** the AI always knows what page the user is on and what actions are available
- **Streaming:** responses appear token-by-token, no waiting for a full response
- **Actionable:** the AI can trigger page mutations (filter logs, switch views) without copy-paste
- **Extensible:** new pages can register actions; new block types can be added independently
---
## 2. Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ User │
└────────────────────────────┬────────────────────────────────────────┘
│ types text / voice / file
┌─────────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────┐ │
│ │ Panel │ │ Modal │ │ Full-Screen Page │ │
│ │ (drawer) │ │ (Cmd+P) │ │ /ai-assistant/:id │ │
│ └──────┬──────┘ └──────┬──────┘ └─────────────┬─────────────┘ │
│ └────────────────┴─────────────────────────┘ │
│ │ all modes share │
│ ┌──────▼──────┐ │
│ │ConversationView│ │
│ │ + ChatInput │ │
│ └──────┬──────┘ │
└───────────────────────────┼─────────────────────────────────────────┘
│ sendMessage()
┌─────────────────────────────────────────────────────────────────────┐
│ Zustand Store (useAIAssistantStore) │
│ │
│ conversations{} isStreaming │
│ activeConversationId streamingContent │
│ isDrawerOpen answeredBlocks{} │
│ isModalOpen │
│ │
│ sendMessage() │
│ 1. push user message │
│ 2. buildContextPrefix() ──► PageActionRegistry.snapshot() │
│ 3. call streamChat(payload) [or mockStreamChat in dev] │
│ 4. accumulate chunks into streamingContent │
│ 5. on done: push assistant message with actions[] │
└──────────────────────────┬──────────────────────────────────────────┘
│ POST /api/v1/assistant/threads
│ (SSE response)
┌─────────────────────────────────────────────────────────────────────┐
│ API Layer (src/api/ai/chat.ts) │
│ │
│ streamChat(payload) → AsyncGenerator<SSEEvent> │
│ Parses data: {...}\n\n SSE frames │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Page Action System │
│ │
│ PageActionRegistry ◄──── usePageActions() hook │
│ (module singleton) (called by each page on mount) │
│ │
│ Registry is read by buildContextPrefix() before every API call. │
│ │
│ AI response → ai-action block → ActionBlock component │
│ → PageActionRegistry.get(actionId).execute(params) │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. User Flows
### 3.1 Basic Chat
```
User opens panel (header icon / Cmd+P / trigger button)
→ Conversation created (or resumed from store)
→ ChatInput focused automatically
User types message → presses Enter
→ User message appended to conversation
→ StreamingMessage (typing indicator) appears
→ SSE stream opens: tokens arrive word-by-word
→ StreamingMessage renders live content
→ Stream ends: StreamingMessage replaced by MessageBubble
→ Follow-up actions (if any) shown as chips on the message
```
### 3.2 AI Applies a Page Action (autoApply)
```
User: "Filter logs for errors from payment-svc"
→ PAGE_CONTEXT injected into wire payload
(includes registered action schemas + current query state)
→ AI responds with plain text + ai-action block
→ ActionBlock mounts with autoApply=true
→ execute() fires immediately on mount — no user confirmation
→ Logs Explorer query updated via redirectWithQueryBuilderData()
→ URL reflects new filters, QueryBuilderV2 UI updates
→ ActionBlock shows "Applied ✓" state (persisted in answeredBlocks)
```
### 3.3 AI Asks a Clarifying Question
```
AI responds with ai-question block
→ InteractiveQuestion renders (radio or checkbox)
→ User selects answer → sendMessage() called automatically
→ Answer persisted in answeredBlocks (survives re-renders / mode switches)
→ Block shows answered state on re-mount
```
### 3.4 AI Requests Confirmation
```
AI responds with ai-confirm block
→ ConfirmBlock renders Accept / Reject buttons
→ Accept → sendMessage(acceptText)
→ Reject → sendMessage(rejectText)
→ Block shows answered state, buttons disabled
```
### 3.5 Modal → Panel Minimize
```
User opens modal (Cmd+P), interacts with AI
User clicks minimize button ()
→ minimizeModal(): isModalOpen=false, isDrawerOpen=true (atomic)
→ Same conversation continues in the side panel
→ No data loss, streaming state preserved
```
### 3.6 Panel → Full Screen Expand
```
User clicks Maximize in panel header
→ closeDrawer() called
→ navigate to /ai-assistant/:conversationId
→ Full-screen page renders same conversation
→ TopNav (timepicker header) hidden on this route
→ SideNav remains visible
```
### 3.7 Voice Input
```
User clicks mic button in ChatInput
→ SpeechRecognition.start()
→ isListening=true (mic turns red, CSS pulse animation)
→ Interim results: textarea updates live as user speaks
→ Recognition ends (auto pause detection or manual click)
→ Final transcript committed to committedTextRef
→ User reviews / edits text, then sends normally
```
### 3.8 Resize Panel
```
User hovers over left edge of panel
→ Resize handle highlights (purple line)
User drags left/right
→ panel width updates live (min 380px, max 800px)
→ document.body.cursor = 'col-resize' during drag
→ text selection disabled during drag
```
---
## 4. UI Modes and Transitions
```
┌──────────────────┐
│ All Closed │
│ (trigger shown) │
└────────┬─────────┘
┌──────────────┼──────────────┐
│ │ │
Click trigger Cmd+P navigate to
│ │ /ai-assistant/:id
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌─────────────┐
│ Panel │ │ Modal │ │ Full-Screen │
│ (drawer) │ │ (portal) │ │ Page │
└────┬─────┘ └────┬─────┘ └──────┬──────┘
│ │ │
┌────▼──────────────▼───────────────▼────┐
│ ConversationView (shared component) │
└─────────────────────────────────────────┘
Transitions:
Panel → Full-Screen : Maximize → closeDrawer() + history.push()
Modal → Panel : Minimize → minimizeModal()
Modal → Full-Screen : Maximize → closeModal() + history.push()
Any → Closed : X button or Escape key
Visibility rules:
Header AI icon : hidden when isDrawerOpen=true
Trigger button : hidden when isDrawerOpen || isModalOpen || isFullScreenPage
TopNav (timepicker): hidden when pathname.startsWith('/ai-assistant/')
```
---
## 5. Control Flow: UI → API → UI
### 5.1 Message Send
```
ChatInput.handleSend()
├── setText('') // clear textarea
├── committedTextRef.current = '' // clear voice accumulator
└── store.sendMessage(text, attachments)
├── Push userMessage to conversations[id].messages
├── set isStreaming=true, streamingContent=''
├── buildContextPrefix()
│ └── PageActionRegistry.snapshot()
│ → returns PageActionDescriptor[] (ids, schemas, current context)
│ → serialized as [PAGE_CONTEXT]...[/PAGE_CONTEXT] string
├── Build wire payload:
│ {
│ conversationId,
│ messages: history.map((m, i) => ({
│ role: m.role,
│ content: i === last && role==='user'
│ ? contextPrefix + m.content // wire only, never stored
│ : m.content
│ }))
│ }
├── for await (event of streamChat(payload)):
│ ├── streamingContent += event.content // triggers StreamingMessage re-render
│ └── if event.done: finalActions = event.actions; break
├── Push assistantMessage { id: serverMessageId, content, actions }
└── set isStreaming=false, streamingContent=''
```
### 5.2 Streaming Render Pipeline
```
streamingContent (Zustand state)
→ StreamingMessage component (rendered while isStreaming=true)
→ react-markdown
→ RichCodeBlock (custom code renderer)
→ BlockRegistry.get(lang) → renders chart / table / action / etc.
On stream end:
streamingContent → assistantMessage.content (frozen in store)
StreamingMessage removed, MessageBubble added with same content
MessageBubble renders through identical markdown pipeline
```
### 5.3 PAGE_CONTEXT Wire Format
The context prefix is prepended to the last user message in the API payload **only**. It is never stored in the conversation or shown in the UI.
```
[PAGE_CONTEXT]
actions:
- id: logs.runQuery
description: "Replace all log filters and re-run the query"
params: {"filters": {"type": "array", "items": {...}}}
- id: logs.addFilter
description: "Append a single key/op/value filter"
params: {"key": {...}, "op": {...}, "value": {...}}
state:
logs.runQuery: {"currentFilters": [...], "currentView": "list"}
[/PAGE_CONTEXT]
User's actual message text here
```
---
## 6. SSE Response Handling
### 6.1 Wire Format
**Request:**
```
POST /api/v1/assistant/threads
Content-Type: application/json
{
"conversationId": "uuid",
"messages": [
{ "role": "user", "content": "[PAGE_CONTEXT]...[/PAGE_CONTEXT]\nUser text" },
{ "role": "assistant", "content": "Previous assistant turn" },
...
]
}
```
**Response (SSE stream):**
```
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"I'll ","done":false,"actions":[]}\n\n
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"update ","done":false,"actions":[]}\n\n
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"the query.","done":true,"actions":[
{"id":"act-1","label":"Add another filter","kind":"follow_up","payload":{},"expiresAt":null}
]}\n\n
```
### 6.2 SSE Parsing (src/api/ai/chat.ts)
```
fetch() → ReadableStream → TextDecoder → string chunks
→ lineBuffer accumulates across chunks (handles partial lines)
→ split on '\n\n' (SSE event boundary)
→ for each complete part:
find line starting with 'data: '
strip prefix → parse JSON → yield SSEEvent
→ '[DONE]' sentinel → stop iteration
→ malformed JSON → skip silently
→ finally: reader.releaseLock()
```
### 6.3 Structured Content in the Stream
The AI embeds block payloads as markdown fenced code blocks with `ai-*` language tags inside the `content` stream. These are parsed live as tokens arrive:
````markdown
Here are the results:
```ai-graph
{
"title": "p99 Latency",
"datasets": [...]
}
```
The spike started at 14:45.
````
React-markdown renders the code block → `RichCodeBlock` detects the `ai-` prefix → looks up `BlockRegistry` → renders the chart/table/action component.
### 6.4 actions[] Array
Actions arrive on the **final** SSE event (`done: true`). They are stored on the `Message` object. Each action's `kind` determines UI behavior:
| Kind | Behavior |
|---|---|
| `follow_up` | Rendered as suggestion chip; click sends as new message |
| `open_resource` | Opens a SigNoz resource (trace, log, dashboard) |
| `navigate` | Navigates to a SigNoz route |
| `apply_filter` | Directly triggers a registered page action |
| `open_docs` | Opens a documentation URL |
| `undo` | Reverts the last page mutation |
| `revert` | Reverts to a specified previous state |
---
## 7. Page Action System
### 7.1 Concepts
| Concept | Description |
|---|---|
| `PageAction<TParams>` | An action a page exposes to the AI: id, description, JSON Schema params, `execute()`, optional `getContext()`, optional `autoApply` |
| `PageActionRegistry` | Module-level singleton (`Map<pageId, actions[]>` + `Map<actionId, action>`) |
| `usePageActions(pageId, actions)` | React hook — registers on mount, unregisters on unmount |
| `PageActionDescriptor` | Serializable version of `PageAction` (no functions) — sent to AI via PAGE_CONTEXT |
| `AIActionBlock` | Shape the AI emits when invoking an action: `{ actionId, description, parameters }` |
### 7.2 Lifecycle
```
Page component mounts
└── usePageActions('logs-explorer', actions)
└── PageActionRegistry.register('logs-explorer', actions)
→ added to _byPage map (for bulk unregister)
→ added to _byId map (for O(1) lookup by actionId)
User sends any message
└── buildContextPrefix()
└── PageActionRegistry.snapshot()
→ returns PageActionDescriptor[] with current context values
AI response contains ```ai-action block
└── ActionBlock component mounts
├── PageActionRegistry.get(actionId) → PageAction with execute()
└── if autoApply: execute(params) on mount
else: render confirmation card, execute on user click
Page component unmounts
└── usePageActions cleanup
└── PageActionRegistry.unregister('logs-explorer')
→ all actions for this page removed from both maps
```
### 7.3 ActionBlock State Machine
**autoApply: true** (fires immediately on mount):
```
mount
→ hasFired ref guard (prevents double-fire in React StrictMode)
→ PageActionRegistry.get(actionId).execute(params)
→ render: loading spinner
→ success: "Applied ✓" state, markBlockAnswered(messageId, 'applied')
→ error: error state with message, markBlockAnswered(messageId, 'error:...')
```
**autoApply: false** (user must confirm):
```
mount
→ render: description + parameter summary + Apply / Dismiss buttons
→ Apply clicked:
→ execute(params) → loading → applied state
→ markBlockAnswered(messageId, 'applied')
→ Dismiss clicked:
→ markBlockAnswered(messageId, 'dismissed')
```
**Re-mount (scroll / mode switch):**
```
mount
→ answeredBlocks[messageId] exists
→ render answered state directly (skip pending UI)
```
---
## 8. Block Rendering System
### 8.1 Registration
`src/container/AIAssistant/components/blocks/index.ts` registers all built-in types at import time (side-effect import):
```typescript
BlockRegistry.register('action', ActionBlock);
BlockRegistry.register('question', InteractiveQuestion);
BlockRegistry.register('confirm', ConfirmBlock);
BlockRegistry.register('timeseries', TimeseriesBlock);
BlockRegistry.register('barchart', BarChartBlock);
BlockRegistry.register('piechart', PieChartBlock);
BlockRegistry.register('linechart', LineChartBlock);
BlockRegistry.register('graph', LineChartBlock); // alias
```
### 8.2 Render Pipeline
```
MessageBubble (assistant message)
└── react-markdown
└── components={{ code: RichCodeBlock }}
└── RichCodeBlock
├── lang.startsWith('ai-') ?
│ yes → BlockRegistry.get(lang.slice(3))
│ → parse JSON content
│ → render block component
└── no → render plain <code> element
```
### 8.3 Block Component Interface
All block components receive:
```typescript
interface BlockProps {
content: string; // raw JSON string from the fenced code block body
}
```
Blocks access shared context via:
```typescript
const { messageId } = useContext(MessageContext); // for answeredBlocks key
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
const sendMessage = useAIAssistantStore(s => s.sendMessage); // for interactive blocks
```
### 8.4 Block Types Reference
| Tag | Component | Purpose |
|---|---|---|
| `ai-action` | `ActionBlock` | Invokes a registered page action |
| `ai-question` | `InteractiveQuestion` | Radio or checkbox user selection |
| `ai-confirm` | `ConfirmBlock` | Binary accept / reject prompt |
| `ai-timeseries` | `TimeseriesBlock` | Tabular data with rows and columns |
| `ai-barchart` | `BarChartBlock` | Horizontal / vertical bar chart |
| `ai-piechart` | `PieChartBlock` | Doughnut / pie chart |
| `ai-linechart` | `LineChartBlock` | Multi-series line chart |
| `ai-graph` | `LineChartBlock` | Alias for `ai-linechart` |
---
## 9. State Management
### 9.1 Store Shape (Zustand + Immer)
```typescript
interface AIAssistantStore {
// UI
isDrawerOpen: boolean;
isModalOpen: boolean;
activeConversationId: string | null;
// Data
conversations: Record<string, Conversation>;
// Streaming
streamingContent: string; // accumulates token-by-token during SSE stream
isStreaming: boolean;
// Block answer persistence
answeredBlocks: Record<string, string>; // messageId → answer string
}
```
### 9.2 Conversation Structure
```typescript
interface Conversation {
id: string;
messages: Message[];
createdAt: number;
updatedAt?: number;
title?: string; // auto-derived from first user message (60 char max)
}
interface Message {
id: string; // server messageId for assistant turns, uuidv4 for user
role: 'user' | 'assistant';
content: string;
attachments?: MessageAttachment[];
actions?: AssistantAction[]; // follow-up actions, present on final assistant message only
createdAt: number;
}
```
### 9.3 Streaming State Machine
```
idle
→ sendMessage() called
→ isStreaming=true, streamingContent=''
streaming
→ each SSE chunk: streamingContent += event.content (triggers StreamingMessage re-render)
→ done event: isStreaming=false, streamingContent=''
→ assistant message pushed to conversation
idle (settled)
→ MessageBubble renders final frozen content
→ ChatInput re-enabled (disabled={isStreaming})
```
### 9.4 Answered Block Persistence
Interactive blocks call `markBlockAnswered(messageId, answer)` on completion. On re-mount, blocks check `answeredBlocks[messageId]` and render the answered state directly. This ensures:
- Scrolling away and back does not reset blocks
- Switching UI modes (panel → full-screen) does not reset blocks
- Blocks cannot be answered twice
---
## 10. Page-Specific Actions
### 10.1 Logs Explorer
**File:** `src/pages/LogsExplorer/aiActions.ts`
**Registered in:** `src/pages/LogsExplorer/index.tsx` via `usePageActions('logs-explorer', aiActions)`
| Action ID | autoApply | Description |
|---|---|---|
| `logs.runQuery` | `true` | Replace all filters in the query builder and re-run |
| `logs.addFilter` | `true` | Append a single `key / op / value` filter |
| `logs.changeView` | `true` | Switch between list / timeseries / table views |
| `logs.saveView` | `false` | Save current query as a named view (requires confirmation) |
**Critical implementation detail:** All query mutations use `redirectWithQueryBuilderData()`, not `handleSetQueryData`. The Logs Explorer's `QueryBuilderV2` is URL-driven — `compositeQuery` in the URL is the source of truth for displayed filters. `handleSetQueryData` updates React state only; `redirectWithQueryBuilderData` syncs the URL, making changes visible in the UI.
**Context shape provided to AI:**
```typescript
getContext: () => ({
currentFilters: currentQuery.builder.queryData[0].filters.items,
currentView: currentView, // 'list' | 'timeseries' | 'table'
})
```
---
## 11. Voice Input
### 11.1 Hook: useSpeechRecognition
**File:** `src/container/AIAssistant/hooks/useSpeechRecognition.ts`
```typescript
const { isListening, isSupported, start, stop, transcript, isFinal } =
useSpeechRecognition({ lang: 'en-US', onError });
```
Exposes `transcript` and `isFinal` as React state (not callbacks) so `ChatInput` reacts via `useEffect([transcript, isFinal])`, eliminating stale closure issues.
### 11.2 Interim vs Final Handling
```
onresult (isFinal=false) → pendingInterim = text → setTranscript(text), setIsFinal(false)
onresult (isFinal=true) → pendingInterim = '' → setTranscript(text), setIsFinal(true)
onend (pendingInterim) → setTranscript(pendingInterim), setIsFinal(true)
↑ fallback: Chrome often skips the final onresult when stop() is called manually
```
### 11.3 Text Accumulation in ChatInput
```
committedTextRef.current = '' // tracks finalized text (typed + confirmed speech)
isFinal=false (interim):
setText(committedTextRef.current + ' ' + transcript)
// textarea shows live speech; committedTextRef unchanged
isFinal=true (final):
committedTextRef.current += ' ' + transcript
setText(committedTextRef.current)
// both textarea and ref updated — text is now "committed"
User types manually:
setText(e.target.value)
committedTextRef.current = e.target.value
// keeps both in sync so next speech session appends correctly
```
---
## 12. File Attachments
`ChatInput` uses Ant Design `Upload` with `beforeUpload` returning `false` (prevents auto-upload). Files accumulate in `pendingFiles: UploadFile[]` state. On send, files are converted to data URIs (`fileToDataUrl`) and stored on the `Message` as `attachments[]`.
**Accepted types:** `image/*`, `.pdf`, `.txt`, `.log`, `.csv`, `.json`
**Rendered in MessageBubble:**
- Images → inline `<img>` preview
- Other files → file badge chip (name + type)
---
## 13. Development Mode (Mock)
Set `VITE_AI_MOCK=true` in `.env.local` to use the mock API instead of the real SSE endpoint.
```typescript
// store/useAIAssistantStore.ts
const USE_MOCK_AI = import.meta.env.VITE_AI_MOCK === 'true';
const chat = USE_MOCK_AI ? mockStreamChat : streamChat;
```
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. It selects canned responses from keyword matching on the last user message and simulates word-by-word streaming with 1545ms random delays.
**Trigger keywords:**
| Keyword(s) | Response type |
|---|---|
| `filter logs`, `payment` + `error` | `ai-action`: logs.runQuery |
| `add filter` | `ai-action`: logs.addFilter |
| `change view` / `timeseries view` | `ai-action`: logs.changeView |
| `save view` | `ai-action`: logs.saveView |
| `error` / `exception` | Error rates table + trace snippet |
| `latency` / `p99` / `graph` | Line chart (p99 latency) |
| `bar` / `top service` | Bar chart (error count) |
| `pie` / `distribution` | Pie / doughnut chart |
| `timeseries` / `table` | Timeseries data table |
| `log` | Top log errors summary |
| `confirm` / `alert` / `anomal` | `ai-confirm` block |
| `environment` / `question` | `ai-question` (radio) |
| `level` / `select` / `filter` | `ai-question` (checkbox) |
---
## 14. Adding a New Page's Actions
### Step 1 — Create an aiActions file
```typescript
// src/pages/TracesExplorer/aiActions.ts
import { PageAction } from 'container/AIAssistant/pageActions/types';
interface FilterTracesParams {
service: string;
minDurationMs?: number;
}
export function tracesFilterAction(deps: {
currentQuery: Query;
redirectWithQueryBuilderData: (q: Query) => void;
}): PageAction<FilterTracesParams> {
return {
id: 'traces.filter', // globally unique: pageName.actionName
description: 'Filter traces by service name and minimum duration',
autoApply: true,
parameters: {
type: 'object',
properties: {
service: { type: 'string', description: 'Service name to filter by' },
minDurationMs: { type: 'number', description: 'Minimum span duration in ms' },
},
required: ['service'],
},
execute: async ({ service, minDurationMs }) => {
// Build updated query and redirect
deps.redirectWithQueryBuilderData(buildUpdatedQuery(service, minDurationMs));
return { summary: `Filtered traces for ${service}` };
},
getContext: () => ({
currentFilters: deps.currentQuery.builder.queryData[0].filters.items,
}),
};
}
```
### Step 2 — Register in the page component
```typescript
// src/pages/TracesExplorer/index.tsx
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { tracesFilterAction } from './aiActions';
function TracesExplorer() {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const aiActions = useMemo(() => [
tracesFilterAction({ currentQuery, redirectWithQueryBuilderData }),
], [currentQuery, redirectWithQueryBuilderData]);
usePageActions('traces-explorer', aiActions);
// ... rest of component
}
```
**Rules:**
- `pageId` must be unique across pages (kebab-case convention)
- `actionId` must be globally unique — use `pageName.actionName` convention
- `actions` array **must be memoized** (`useMemo`) — identity change triggers re-registration
- For URL-driven state (QueryBuilder), always use the URL-sync API; never use `handleSetQueryData` alone
- `getContext()` should return only what the AI needs to make decisions — keep it minimal
---
## 15. Adding a New Block Type
### Step 1 — Create the component
```typescript
// src/container/AIAssistant/components/blocks/MyBlock.tsx
import { useContext } from 'react';
import MessageContext from '../MessageContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
interface MyBlockPayload {
title: string;
items: string[];
}
export default function MyBlock({ content }: { content: string }): JSX.Element {
const payload = JSON.parse(content) as MyBlockPayload;
const { messageId } = useContext(MessageContext);
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
const answered = useAIAssistantStore(s => s.answeredBlocks[messageId]);
if (answered) return <div className="ai-block--answered">Done</div>;
return (
<div>
<h4>{payload.title}</h4>
{/* ... */}
</div>
);
}
```
### Step 2 — Register in index.ts
```typescript
// src/container/AIAssistant/components/blocks/index.ts
import MyBlock from './MyBlock';
BlockRegistry.register('myblock', MyBlock);
```
### Step 3 — AI emits the block tag
````markdown
```ai-myblock
{
"title": "Something",
"items": ["a", "b"]
}
```
````
---
## 16. Data Contracts
### 16.1 API Request
```typescript
POST /api/v1/assistant/threads
{
conversationId: string,
messages: Array<{
role: 'user' | 'assistant',
content: string // last user message includes [PAGE_CONTEXT]...[/PAGE_CONTEXT] prefix
}>
}
```
### 16.2 SSE Event Schema
```typescript
interface SSEEvent {
type: 'message';
messageId: string; // server-assigned; consistent across all chunks of one turn
role: 'assistant';
content: string; // incremental chunk — NOT cumulative
done: boolean; // true on the last event of a turn
actions: Array<{
id: string;
label: string;
kind: 'follow_up' | 'open_resource' | 'navigate' | 'apply_filter' | 'open_docs' | 'undo' | 'revert';
payload: Record<string, unknown>;
expiresAt: string | null; // ISO-8601 or null
}>;
}
```
### 16.3 ai-action Block Payload (embedded in content stream)
```typescript
{
actionId: string, // must match a registered PageAction.id
description: string, // shown in the confirmation card (autoApply=false)
parameters: Record<string, unknown> // must conform to the action's JSON Schema
}
```
### 16.4 PageAction Interface
```typescript
interface PageAction<TParams = Record<string, any>> {
id: string;
description: string;
parameters: JSONSchemaObject;
execute: (params: TParams) => Promise<{ summary: string; data?: unknown }>;
getContext?: () => unknown; // called on every sendMessage() to populate PAGE_CONTEXT
autoApply?: boolean; // default false
}
```
---
## 17. Key Design Decisions
### Context injection is wire-only
PAGE_CONTEXT is injected into the wire payload but never stored or shown in the UI. This keeps conversations readable, avoids polluting history with system context, and ensures the AI always gets fresh page state on every message rather than stale state from when the conversation started.
### URL-driven query builders require URL-sync APIs
Pages that use URL-driven state (e.g., `QueryBuilderV2` with `compositeQuery` URL param) **must** use the URL-sync API (`redirectWithQueryBuilderData`) when actions mutate query state. Using React `setState` alone does not update the URL, so the displayed filters do not change. This was the root cause of the first major bug in the Logs Explorer integration.
### autoApply co-located with action definition
The `autoApply` flag lives on the `PageAction` definition, not in the UI layer. The page that owns the action knows whether it is safe to apply without confirmation. Additive / reversible actions use `autoApply: true`. Actions that create persistent artifacts (saved views, alert rules) use `autoApply: false`.
### Transcript-as-state for voice input
`useSpeechRecognition` exposes `transcript` and `isFinal` as React state rather than using an `onTranscript` callback. The callback approach had a race condition: recognition events could fire before the `useEffect` that wired up the callback had run, leaving `onTranscriptRef.current` unset. State-based approach uses normal React reactivity with no timing dependency.
### Block answer persistence across re-mounts
Interactive blocks persist their answered state to `answeredBlocks[messageId]` in the Zustand store. Without this, switching UI modes or scrolling away and back would reset blocks to their unanswered state, allowing the user to re-submit answers and send duplicate messages to the AI.
### Panel resize is not persisted
Panel width resets to 380px on close/reopen. If persistence is needed, save `panelWidth` to `localStorage` in the drag `onMouseUp` handler and initialize `useState` from it.
### Mock API shares the same interface
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. The store switches between them via `VITE_AI_MOCK=true`. This means the mock exercises the exact same store code path as production — no separate code branch to maintain.

View File

@@ -1,8 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
export type AIAssistantVariant = 'panel' | 'page' | 'modal';
export const VariantContext = createContext<AIAssistantVariant>('page');
export const useVariant = (): AIAssistantVariant => useContext(VariantContext);

View File

@@ -1,32 +0,0 @@
@mixin scrollbar($width: 0.3rem) {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s;
&:hover {
scrollbar-color: var(--bg-slate-400) transparent;
}
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
transition: background 0.2s;
}
&:hover::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
}

View File

@@ -1,80 +0,0 @@
/**
* AIAssistantIcon — SigNoz AI Assistant icon (V2 — Minimal Line).
*
* Single-weight stroke outline of a bear face. Inherits `currentColor` so it
* adapts to any dark/light context automatically. The only hard-coded color is
* the SigNoz red (#E8432D) eye bar — one bold accent, nothing else.
*/
interface AIAssistantIconProps {
size?: number;
className?: string;
}
export default function AIAssistantIcon({
size = 24,
className,
}: AIAssistantIconProps): JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-label="AI Assistant"
role="img"
>
{/* Ears */}
<path
d="M8 13.5 C8 8 5 6 5 11 C5 14 7 15.5 9.5 15.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M24 13.5 C24 8 27 6 27 11 C27 14 25 15.5 22.5 15.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
{/* Head */}
<rect
x="7"
y="12"
width="18"
height="15"
rx="7"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
{/* Eye bar — SigNoz red, the only accent */}
<line
x1="10"
y1="18"
x2="22"
y2="18"
stroke="#E8432D"
strokeWidth="2.5"
strokeLinecap="round"
/>
<circle cx="12" cy="18" r="1" fill="#E8432D" />
<circle cx="20" cy="18" r="1" fill="#E8432D" />
{/* Nose */}
<ellipse
cx="16"
cy="23.5"
rx="1.6"
ry="1"
stroke="currentColor"
strokeWidth="1.2"
fill="none"
/>
</svg>
);
}

View File

@@ -1,118 +0,0 @@
$radius: 4px;
.section {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
width: 100%;
padding: 8px;
margin-top: 16px;
border: 1px solid var(--l2-border);
border-radius: $radius;
// Background, padding-x, and rounding are inherited from the parent
// bubble — the section sits inside the assistant bubble as its last
// block, so it matches the bubble's width by definition.
}
.heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l3-foreground, var(--l2-foreground));
}
.headingIcon {
color: var(--bg-amber-400, #fbbf24);
flex-shrink: 0;
}
.list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--l2-border);
border-radius: $radius;
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 163.636% */
letter-spacing: -0.055px;
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease;
&:hover:not(:disabled) {
background: var(--l2-background);
border-color: var(--l3-border, var(--l2-border));
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&.error {
border-color: var(--bg-cherry-500, #ef4444);
color: var(--bg-cherry-500, #ef4444);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground, var(--l2-foreground));
}
&:hover:not(:disabled) svg {
color: var(--primary, var(--bg-robin-500));
}
&.error svg {
color: var(--bg-cherry-500, #ef4444);
}
}
.spin {
color: var(--primary, var(--bg-robin-500)) !important;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.chipLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.chipState {
font-size: 10px;
font-weight: 500;
color: var(--l3-foreground, var(--l2-foreground));
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border: 1px solid var(--l2-border);
border-radius: 999px;
margin-left: 2px;
}

View File

@@ -1,289 +0,0 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Button, Tooltip } from '@signozhq/ui';
import type { MessageActionDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { MessageActionKindDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { restoreExecution, revertExecution, undoExecution } from 'api/ai/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
BookOpen,
Check,
ExternalLink,
Eye,
Filter,
LoaderCircle,
MessageCircle,
RotateCcw,
Sparkles,
TriangleAlert,
Undo,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import styles from './ActionsSection.module.scss';
interface ActionsSectionProps {
actions: MessageActionDTO[];
}
type ChipState = 'idle' | 'loading' | 'success' | 'error';
interface ChipResult {
state: ChipState;
error?: string;
}
/** Maps each MessageActionKindDTO to its display icon. */
function ActionIcon({
kind,
size = 12,
}: {
kind: MessageActionDTO['kind'];
size?: number;
}): JSX.Element {
switch (kind) {
case MessageActionKindDTO.undo:
return <Undo size={size} />;
case MessageActionKindDTO.revert:
return <RotateCcw size={size} />;
case MessageActionKindDTO.restore:
return <ArchiveRestore size={size} />;
case MessageActionKindDTO.follow_up:
return <MessageCircle size={size} />;
case MessageActionKindDTO.open_resource:
return <Eye size={size} />;
case MessageActionKindDTO.open_docs:
return <BookOpen size={size} />;
case MessageActionKindDTO.apply_filter:
return <Filter size={size} />;
default:
return <ExternalLink size={size} />;
}
}
/**
* Resolves an `open_resource` action to an in-app route.
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
* saved_view, service, and the *_explorer signals.
*/
function resourceRoute(
resourceType: string,
resourceId: string,
): string | null {
switch (resourceType) {
case 'dashboard':
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case 'alert': {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case 'service':
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case 'saved_view':
// No detail route — saved views land on the list page.
// Caller may provide signal-aware metadata in future; default to logs.
return ROUTES.LOGS_SAVE_VIEWS;
case 'logs_explorer':
return ROUTES.LOGS_EXPLORER;
case 'traces_explorer':
return ROUTES.TRACES_EXPLORER;
case 'metrics_explorer':
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}
/** Picks the right rollback API call for a given action kind. */
function rollbackCall(
kind: MessageActionDTO['kind'],
): ((id: string) => Promise<unknown>) | null {
switch (kind) {
case MessageActionKindDTO.undo:
return undoExecution;
case MessageActionKindDTO.revert:
return revertExecution;
case MessageActionKindDTO.restore:
return restoreExecution;
default:
return null;
}
}
/** Past-tense status label shown on a successful rollback chip. */
function rollbackSuccessLabel(kind: MessageActionDTO['kind']): string {
switch (kind) {
case MessageActionKindDTO.undo:
return 'Undone';
case MessageActionKindDTO.revert:
return 'Reverted';
case MessageActionKindDTO.restore:
return 'Restored';
default:
return 'Done';
}
}
/**
* Renders the actions attached to a single assistant message.
*
* Hidden when the message has no actions. Rendered inside `MessageBubble`
* between the message body and the feedback bar.
*/
export default function ActionsSection({
actions,
}: ActionsSectionProps): JSX.Element | null {
const history = useHistory();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
// the underlying action.state catches up via a fresh thread fetch.
const [results, setResults] = useState<Record<string, ChipResult>>({});
if (actions.length === 0) {
return null;
}
const setResult = (key: string, result: ChipResult): void => {
setResults((prev) => ({ ...prev, [key]: result }));
};
const runRollback = async (
key: string,
action: MessageActionDTO,
): Promise<void> => {
const call = rollbackCall(action.kind);
if (!call || !action.actionMetadataId) {
return;
}
setResult(key, { state: 'loading' });
try {
await call(action.actionMetadataId);
setResult(key, { state: 'success' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed';
setResult(key, { state: 'error', error: message });
}
};
const handleClick = (key: string, action: MessageActionDTO): void => {
switch (action.kind) {
case MessageActionKindDTO.open_docs: {
if (action.url) {
openInNewTab(action.url);
}
break;
}
case MessageActionKindDTO.follow_up: {
if (action.label) {
void sendMessage(action.label);
}
break;
}
case MessageActionKindDTO.open_resource: {
if (action.resourceType && action.resourceId) {
const path = resourceRoute(action.resourceType, action.resourceId);
if (path) {
history.push(path);
}
}
break;
}
case MessageActionKindDTO.undo:
case MessageActionKindDTO.revert:
case MessageActionKindDTO.restore: {
void runRollback(key, action);
break;
}
default:
// apply_filter is left as a no-op until the query serialization
// contract between the AI server and the explorer pages is defined.
break;
}
};
return (
<div className={styles.section}>
<div className={styles.heading}>
<Sparkles size={11} className={styles.headingIcon} />
<span className={styles.headingText}>Suggested actions</span>
</div>
<div className={styles.list}>
{actions.map((action, i) => {
// Stable per-action key. `actionMetadataId` alone isn't unique —
// the server can attach the same id to multiple kinds (e.g. an
// `undo` and `revert` chip for the same operation), so we always
// include the kind. Falls back to label + index when the id is
// missing (e.g. follow_up / open_docs).
const key = action.actionMetadataId
? `${action.kind}:${action.actionMetadataId}`
: `${action.kind}:${action.label}:${i}`;
const result = results[key];
const isLoading = result?.state === 'loading';
const isSuccess = result?.state === 'success';
const isError = result?.state === 'error';
// `action.state` is a free-form string from the server (e.g. "active",
// "applied"). Without a documented terminal vocabulary we don't auto-
// disable on it — only the local in-flight click result does. The state
// is still surfaced visually via the suffix pill below.
const isDisabled = isLoading || isSuccess;
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
// Only show the suffix pill after a successful rollback ("Undone",
// "Reverted", "Restored"). The raw server-side `action.state` (e.g.
// "active") is informational and not surfaced to the user.
const stateLabel = isSuccess
? rollbackSuccessLabel(action.kind)
: undefined;
let icon: JSX.Element;
if (isLoading) {
icon = <LoaderCircle size={12} className={styles.spin} />;
} else if (isSuccess) {
icon = <Check size={12} />;
} else if (isError) {
icon = <TriangleAlert size={12} />;
} else {
icon = <ActionIcon kind={action.kind} />;
}
const chip = (
<Button
variant="outlined"
color="secondary"
size="sm"
className={cx(styles.chip, { [styles.error]: isError })}
onClick={(): void => handleClick(key, action)}
disabled={isDisabled}
aria-label={action.label}
prefix={icon}
suffix={
stateLabel ? (
<span className={styles.chipState}>{stateLabel}</span>
) : undefined
}
>
<span className={styles.chipLabel}>{action.label}</span>
</Button>
);
return tooltip ? (
<Tooltip key={key} title={tooltip}>
{chip}
</Tooltip>
) : (
<span key={key}>{chip}</span>
);
})}
</div>
</div>
);
}

View File

@@ -1,284 +0,0 @@
@use '../_scrollbar' as *;
$radius: 4px;
.card {
border: 1px solid var(--l1-border);
border-radius: $radius;
padding: 12px;
background: var(--l1-background);
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
&.decided {
border-color: var(--l2-border);
background: transparent;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.shieldIcon {
flex-shrink: 0;
color: var(--primary);
}
.headerLabel {
font-size: 12px;
font-weight: 600;
color: var(--l1-foreground);
}
.resourceBadge {
margin-left: auto;
font-size: 10px;
font-family: var(--font-mono, monospace);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 1px 5px;
color: var(--l2-foreground);
}
.summary {
font-size: 13px;
color: var(--l1-foreground);
margin: 0;
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 {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
&.before .diffLabel {
color: var(--bg-cherry-500, #ef4444);
}
&.after .diffLabel {
color: var(--bg-forest-500, #22c55e);
}
}
.diffBlockHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.diffLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.diffJson {
font-family: var(--font-mono, monospace);
font-size: 11px;
background: var(--l2-background);
border-radius: $radius;
padding: 5px 7px;
margin: 0;
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 {
display: flex;
gap: 6px;
}
.statusIcon {
flex-shrink: 0;
&.ok {
color: var(--bg-forest-500, #22c55e);
}
&.no {
color: var(--l3-foreground, #6b7280);
}
}
.statusText {
font-size: 13px;
color: var(--l2-foreground);
}

View File

@@ -1,472 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
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';
import styles from './ApprovalCard.module.scss';
interface ApprovalCardProps {
conversationId: string;
approval: ApprovalEventDTO;
}
/**
* Rendered when the agent emits an `approval` SSE event.
* The agent has paused execution; the user must approve or reject
* before the stream resumes on a new execution.
*/
export default function ApprovalCard({
conversationId,
approval,
}: ApprovalCardProps): JSX.Element {
const approveAction = useAIAssistantStore((s) => s.approveAction);
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
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');
await approveAction(conversationId, approval.approvalId);
};
const handleReject = async (): Promise<void> => {
setDecided('rejected');
await rejectAction(conversationId, approval.approvalId);
};
// After decision the card shows a compact confirmation row
if (decided === 'approved') {
return (
<div className={cx(styles.card, styles.decided)}>
<Check size={13} className={cx(styles.statusIcon, styles.ok)} />
<span className={styles.statusText}>Approved resuming</span>
</div>
);
}
if (decided === 'rejected') {
return (
<div className={cx(styles.card, styles.decided)}>
<X size={13} className={cx(styles.statusIcon, styles.no)} />
<span className={styles.statusText}>Rejected.</span>
</div>
);
}
return (
<div className={styles.card}>
<div className={styles.header}>
<Shield size={13} className={styles.shieldIcon} />
<span className={styles.headerLabel}>Action requires approval</span>
<span className={styles.resourceBadge}>
{approval.actionType} · {approval.resourceType}
</span>
</div>
<p className={styles.summary}>{approval.summary}</p>
{approval.diff && (
<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"
size="sm"
onClick={handleApprove}
disabled={isStreaming}
prefix={<Check />}
>
Approve
</Button>
<Button
variant="outlined"
size="sm"
color="secondary"
onClick={handleReject}
disabled={isStreaming}
prefix={<X />}
>
Reject
</Button>
</div>
</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

@@ -1,456 +0,0 @@
@use '../_scrollbar' as *;
$radius: 4px;
.input {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--l1-background);
border-radius: $radius;
padding: 8px;
border: 1px solid var(--l1-border);
transition: border-color 0.15s;
position: relative;
&:focus-within {
border-color: var(--l1-border);
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.contextTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.contextTag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: $radius;
padding: 4px 6px 4px 8px;
color: var(--l1-foreground);
button {
height: auto !important;
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 {
display: flex;
align-items: center;
gap: 8px;
max-width: 220px;
.contextTagCategory {
flex-shrink: 0;
}
.contextTagLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contextTagCategory {
flex-shrink: 0;
}
.contextTagLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextTagRemove {
flex-shrink: 0;
padding: 0 !important;
height: auto !important;
min-height: 0 !important;
}
.attachmentChip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: var(--l3-background);
border-radius: $radius;
padding: 2px 6px 2px 8px;
color: var(--l2-foreground);
max-width: 180px;
}
.attachmentName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentRemove {
flex-shrink: 0;
padding: 0 !important;
height: auto !important;
min-height: 0 !important;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.composer {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 4px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.leftActions,
.rightActions {
display: flex;
align-items: center;
gap: 8px;
}
.attachBtn {
color: var(--l3-foreground, var(--l2-foreground));
}
.contextBtn {
flex-shrink: 0;
gap: 6px;
border-style: dashed !important;
padding-inline: 12px !important;
}
.textarea {
display: block;
width: 100%;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--l1-foreground);
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
font-family: inherit;
@include scrollbar(0.2rem);
&::placeholder {
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.6;
}
}
.charWarning {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
color: var(--bg-sienna-500, #f97316);
background: rgba(249, 115, 22, 0.1);
border: 1px solid rgba(249, 115, 22, 0.35);
border-radius: $radius;
}
.sendBtn {
flex-shrink: 0;
border-radius: $radius;
&.stop {
background: var(--bg-cherry-500, #ef4444) !important;
border-color: var(--bg-cherry-500, #ef4444) !important;
&:hover {
opacity: 0.85;
}
}
}
.contextPopover {
width: 480px !important;
max-width: min(92vw, 480px);
border: 1px solid var(--l2-border);
border-radius: $radius;
background: var(--l1-background);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.3);
padding: 8px;
// Clip horizontal overflow so long entity titles can't poke past the
// popover's right edge. Vertical overflow is handled inside
// `.contextPopoverEntities`.
overflow-x: hidden;
--popover-padding: 0;
}
.contextPopoverContent {
display: grid;
grid-template-columns: 180px 1fr;
min-height: 250px;
min-width: 0;
}
.contextPopoverCategories {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-right: 1px solid var(--l2-border);
}
.contextPopoverCategoryItem {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: $radius;
font-size: 12px;
font-weight: 550;
text-align: left;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l2-background);
color: var(--l1-foreground);
border-color: var(--l2-border);
}
&.active {
background: rgba(59, 130, 246, 0.14);
color: var(--l1-foreground);
border-color: rgba(59, 130, 246, 0.36);
}
}
.contextPopoverRight {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
// Match the previous fixed entity-list height so the inner search +
// scrolling list have a definite container to size against.
height: 320px;
}
.contextPopoverSearch {
padding: 8px;
flex-shrink: 0;
position: relative;
width: 90%;
}
.contextPopoverSearchInput {
width: 100%;
box-sizing: border-box;
}
.contextPopoverEntities {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
min-width: 0;
@include scrollbar(0.2rem);
}
.contextPopoverEntityItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: $radius;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: left;
cursor: pointer;
// Required for the inner span's `text-overflow: ellipsis` to engage —
// flex items default to `min-width: auto` (intrinsic width) and would
// otherwise grow past their parent's width to fit long titles.
min-width: 0;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--l2-background);
border-color: var(--l2-border);
transform: translateY(-1px);
}
&.selected {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.36);
span {
color: var(--l1-foreground);
font-weight: 600;
}
}
}
.contextPopoverEntityItemText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextPopoverEmpty {
font-size: 12px;
color: var(--l3-foreground, var(--l2-foreground));
padding: 10px 8px;
}
.micBtn {
flex-shrink: 0;
}
.micRecording {
display: flex;
align-items: center;
background: var(--l2-background);
border-radius: 999px;
padding: 2px;
gap: 0;
flex-shrink: 0;
}
.micDiscard,
.micStop {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
}
.micDiscard {
background: var(--l2-background);
color: var(--l2-foreground);
transition: color 0.12s;
&:hover {
color: var(--l1-foreground);
}
}
.micWaves {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 20px;
padding: 0 6px;
span {
display: block;
width: 2px;
border-radius: 2px;
background: var(--l1-foreground);
animation: voiceWave 0.9s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.1s;
}
&:nth-child(3) {
animation-delay: 0.2s;
}
&:nth-child(4) {
animation-delay: 0.3s;
}
&:nth-child(5) {
animation-delay: 0.4s;
}
&:nth-child(6) {
animation-delay: 0.3s;
}
&:nth-child(7) {
animation-delay: 0.2s;
}
&:nth-child(8) {
animation-delay: 0.1s;
}
}
}
@keyframes voiceWave {
0%,
100% {
height: 2px;
opacity: 0.4;
}
50% {
height: 12px;
opacity: 1;
}
}
.micStop {
background: var(--bg-cherry-500, #ef4444);
color: #fff;
transition: opacity 0.12s;
&:hover {
opacity: 0.85;
}
}

View File

@@ -1,972 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import cx from 'classnames';
import {
Badge,
Button,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
} from '@signozhq/ui';
import type { UploadFile } from 'antd';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
import type { SuccessResponseV2 } from 'types/api';
import type { Dashboard } from 'types/api/dashboard/getAll';
// eslint-disable-next-line
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
import { MessageAttachment } from '../types';
import { MessageContext } from '../../../api/ai/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
interface ChatInputProps {
onSend: (
text: string,
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => void;
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;
/**
* One-shot draft to push into the textarea — used by the "Edit and
* resend" action on user message bubbles. The wrapper object's
* identity (not just the text) is what triggers the consume effect,
* so editing the same message twice still re-fills the input.
*/
pendingDraft?: { text: string } | null;
/** Called once the pending draft has been moved into the textarea. */
onDraftConsumed?: () => 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;
const WARNING_THRESHOLD = 15000;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
value: string;
}
function toMessageContext(item: SelectedContextItem): MessageContext | null {
switch (item.category) {
case 'Dashboards':
return {
source: 'mention',
type: 'dashboard',
resourceId: item.entityId,
resourceName: item.value,
};
case 'Alerts':
return {
source: 'mention',
type: 'alert',
resourceId: item.entityId,
resourceName: item.value,
};
case 'Services':
return {
source: 'mention',
type: 'service',
resourceId: item.entityId,
resourceName: item.value,
};
default:
return null;
}
}
interface ContextEntityItem {
id: string;
value: string;
}
const CONTEXT_CATEGORY_ICONS: Record<
ContextCategory,
(props: { size?: number }) => JSX.Element
> = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
};
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export default function ChatInput({
onSend,
onCancel,
disabled,
isStreaming = false,
autoContexts,
onDismissAutoContext,
pendingDraft,
onDraftConsumed,
}: ChatInputProps): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [text, setText] = useState('');
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
const [selectedContexts, setSelectedContexts] = useState<
SelectedContextItem[]
>([]);
const [isContextPickerOpen, setIsContextPickerOpen] = useState(false);
const [activeContextCategory, setActiveContextCategory] =
useState<ContextCategory>('Dashboards');
const [pickerSearchQuery, setPickerSearchQuery] = useState('');
const queryClient = useQueryClient();
// Consume one-shot drafts pushed by parent (e.g. "Edit and resend"
// from a user message bubble). Each new wrapper-object identity from
// the parent triggers exactly one fill of the textarea, then we
// notify the parent so it can null its slot for the next push.
useEffect(() => {
if (!pendingDraft) {
return;
}
const draftText = capText(pendingDraft.text);
setText(draftText);
committedTextRef.current = draftText;
textareaRef.current?.focus();
// Defer the parent reset to the end of the microtask queue so the
// state update above has been committed before the parent nulls
// the slot (avoids a race on rapid re-edits).
queueMicrotask(() => onDraftConsumed?.());
// `capText`/refs are stable; deliberately excluded so we only
// re-run when the parent pushes a new draft identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingDraft]);
// 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 {
startTime: now - HOME_SERVICES_INTERVAL,
endTime: now,
};
});
// Stores the already-committed final text so interim results don't overwrite it
const committedTextRef = useRef('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRootRef = useRef<HTMLDivElement>(null);
const capText = useCallback(
(value: string) => value.slice(0, MAX_INPUT_LENGTH),
[],
);
const syncContextPickerFromText = useCallback(
(value: string, caret: number) => {
const beforeCaret = value.slice(0, caret);
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) => {
const wasSelected = selectedContexts.some(
(item) => item.category === category && item.entityId === entityId,
);
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)
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed && pendingFiles.length === 0) {
return;
}
const attachments: MessageAttachment[] = await Promise.all(
pendingFiles.map(async (f) => {
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
return {
name: f.name,
type: f.type ?? 'application/octet-stream',
dataUrl,
};
}),
);
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(
payload,
attachments.length > 0 ? attachments : undefined,
contexts.length > 0 ? contexts : undefined,
);
setText('');
committedTextRef.current = '';
setPendingFiles([]);
setSelectedContexts([]);
textareaRef.current?.focus();
}, [text, pendingFiles, onSend, selectedContexts, autoContexts, capText]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && isContextPickerOpen) {
setIsContextPickerOpen(false);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend, isContextPickerOpen],
);
const removeFile = useCallback((uid: string) => {
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
}, []);
const removeContext = useCallback(
(category: ContextCategory, entityId: string) => {
setSelectedContexts((prev) =>
prev.filter(
(item) => !(item.category === category && item.entityId === entityId),
),
);
},
[],
);
// ── Voice input ────────────────────────────────────────────────────────────
const { isListening, isSupported, start, discard } = useSpeechRecognition({
onTranscript: (transcriptText, isFinal) => {
if (isFinal) {
// Commit: append to whatever the user has already typed
const separator = committedTextRef.current ? ' ' : '';
const next = capText(committedTextRef.current + separator + transcriptText);
committedTextRef.current = next;
setText(next);
} else {
// Interim: live preview appended to committed text, not yet persisted
const separator = committedTextRef.current ? ' ' : '';
setText(capText(committedTextRef.current + separator + transcriptText));
}
},
});
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
// Promote the displayed text (interim included) to committed so handleSend sees it.
committedTextRef.current = capText(text);
// Stop recognition without triggering onTranscript again (would double-append).
discard();
await handleSend();
}, [text, discard, handleSend, capText]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
// triggered PTT in a ref so a late-released modifier (Cmd/Shift) doesn't
// accidentally stop the session. Auto-repeat is suppressed via a
// "session active" ref so a held key only calls `start()` once.
const pttActiveRef = useRef(false);
useEffect(() => {
if (!isSupported) {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const isComboKey =
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
(e.code === 'Space' || e.key === ' ');
if (!isComboKey || disabled || isStreaming) {
return;
}
e.preventDefault();
if (pttActiveRef.current) {
return; // ignore auto-repeat
}
pttActiveRef.current = true;
start();
};
const handleKeyUp = (e: KeyboardEvent): void => {
if (!pttActiveRef.current) {
return;
}
// End on the *first* released key in the combo. macOS browsers
// frequently swallow keyup of regular keys (incl. Space) while
// Cmd is held, so we can't rely on Space-up alone — releasing
// Cmd/Ctrl/Shift must also stop the session.
const isComboKey =
e.code === 'Space' ||
e.key === ' ' ||
e.key === 'Meta' ||
e.key === 'Control' ||
e.key === 'Shift';
if (!isComboKey) {
return;
}
pttActiveRef.current = false;
e.preventDefault();
void handleStopAndSend();
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [isSupported, disabled, isStreaming, start, handleStopAndSend]);
// Each list hook fetches only when its picker tab is actively shown,
// AND treats already-cached data as never stale (`staleTime: Infinity`)
// so an open with a populated cache doesn't trigger a background
// refetch. Net effect: assistant-driven fetches happen exactly once
// per resource list per session, on the first cache miss. Gating on
// `isContextPickerOpen` (not just `activeContextCategory`) is important
// — the latter defaults to 'Dashboards' on every mount, so without the
// picker-open check the dashboards list refetches on every new
// conversation.
const {
data: dashboardsResponse,
isLoading: isDashboardsLoading,
isError: isDashboardsError,
} = useGetAllDashboard({
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
});
const {
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRules({
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
},
});
const {
data: servicesResponse,
isLoading: isServicesLoading,
isFetching: isServicesFetching,
isError: isServicesError,
} = useQueryService({
minTime: servicesTimeRange.startTime * 1e6,
maxTime: servicesTimeRange.endTime * 1e6,
selectedTime,
selectedTags: [],
options: {
enabled: isContextPickerOpen && activeContextCategory === 'Services',
staleTime: Infinity,
},
});
/**
* 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.
*
* Reads passively from the React Query cache via `getQueryData` —
* never triggers a fetch. If the cache is empty (e.g. assistant opened
* on a page that hasn't loaded the resource list yet), the chip falls
* back to a generic label and resolves once the cache fills via the
* picker or another page.
*/
const resolveAutoContextName = useCallback(
(ctx: MessageContext): string => {
if (ctx.type === 'service' && ctx.resourceId) {
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
);
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
);
const rule = cached?.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);
},
[queryClient],
);
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
dashboardsResponse?.data?.map((dashboard) => ({
id: dashboard.id,
value: dashboard.data.title ?? 'Untitled',
})) ?? [],
Alerts:
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
})) ?? [],
Services:
servicesResponse
?.filter((serviceItem) => Boolean(serviceItem.serviceName))
.map((serviceItem, index) => ({
id: serviceItem.serviceName || `service-${index}`,
value: serviceItem.serviceName,
})) ?? [],
};
const contextCategoryStateByCategory: Record<
ContextCategory,
{ isLoading: boolean; isError: boolean }
> = {
Dashboards: {
isLoading: isDashboardsLoading,
isError: isDashboardsError,
},
Alerts: {
isLoading: isAlertsLoading,
isError: isAlertsError,
},
Services: {
isLoading: isServicesLoading || isServicesFetching,
isError: isServicesError,
},
};
// 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 activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
)
: contextEntitiesByCategory[activeContextCategory];
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
contextCategoryStateByCategory[activeContextCategory];
const currentLength = text.length;
const showTextWarning = currentLength >= WARNING_THRESHOLD;
return (
<div className={styles.input} ref={inputRootRef}>
{pendingFiles.length > 0 && (
<div className={styles.attachments}>
{pendingFiles.map((f) => (
<div key={f.uid} className={styles.attachmentChip}>
<span className={styles.attachmentName}>{f.name}</span>
<Button
variant="ghost"
size="icon"
className={styles.attachmentRemove}
onClick={(): void => removeFile(f.uid)}
aria-label={`Remove ${f.name}`}
>
<X size={11} />
</Button>
</div>
))}
</div>
)}
{(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}`}
className={styles.contextTag}
>
<div className={styles.contextTagContent}>
<Badge
color="primary"
variant="outline"
className={styles.contextTagCategory}
>
{contextItem.category}
</Badge>
<span className={styles.contextTagLabel}>{contextItem.value}</span>
</div>
<Button
variant="link"
size="icon"
color="secondary"
className={styles.contextTagRemove}
onClick={(): void =>
removeContext(contextItem.category, contextItem.entityId)
}
aria-label={`Remove ${contextItem.category}: ${contextItem.value} context`}
prefix={<X size={10} />}
></Button>
</div>
))}
</div>
)}
<div className={styles.composer}>
<textarea
ref={textareaRef}
className={styles.textarea}
placeholder="Ask anything… (Shift+Enter for new line)"
value={text}
onChange={(e): void => {
const next = capText(e.target.value);
setText(next);
// Keep committed text in sync when the user edits manually
committedTextRef.current = next;
syncContextPickerFromText(next, e.target.selectionStart ?? next.length);
}}
onKeyDown={handleKeyDown}
disabled={disabled}
maxLength={MAX_INPUT_LENGTH}
rows={2}
/>
</div>
{showTextWarning && (
<div className={styles.charWarning} role="status">
<TriangleAlert size={12} />
<span>
{currentLength}/{MAX_INPUT_LENGTH} characters. Limit is {MAX_INPUT_LENGTH}
.
</span>
</div>
)}
<div className={styles.footer}>
<div className={styles.leftActions}>
<Popover
open={isContextPickerOpen}
onOpenChange={(open): void => {
setIsContextPickerOpen(open);
if (!open) {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}
}}
>
<PopoverTrigger asChild>
<Button
variant="solid"
color="secondary"
size="sm"
disabled={disabled}
onClick={(): void => {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}}
prefix={<Plus size={10} />}
>
Add Context
</Button>
</PopoverTrigger>
<PopoverContent
className={styles.contextPopover}
side="top"
align="end"
sideOffset={8}
>
<div className={styles.contextPopoverContent}>
<div className={styles.contextPopoverCategories}>
{CONTEXT_CATEGORIES.map((category) => {
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
const isActive = activeContextCategory === category;
return (
<div
key={category}
role="tab"
tabIndex={0}
aria-selected={isActive}
className={cx(styles.contextPopoverCategoryItem, {
[styles.active]: isActive,
})}
onClick={(): void => {
setActiveContextCategory(category);
setPickerSearchQuery('');
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setActiveContextCategory(category);
setPickerSearchQuery('');
}
}}
>
<CategoryIcon size={13} />
<span>{category}</span>
</div>
);
})}
</div>
<div className={styles.contextPopoverRight}>
<div className={styles.contextPopoverSearch}>
<Input
type="text"
placeholder={`Search ${activeContextCategory.toLowerCase()}`}
className={styles.contextPopoverSearchInput}
value={pickerSearchQuery}
onChange={(e): void => setPickerSearchQuery(e.target.value)}
prefix={<Search size={12} />}
// Skip the picker's roving keyboard focus — typing here
// shouldn't move category selection.
onKeyDown={(e): void => {
e.stopPropagation();
}}
/>
</div>
<div className={styles.contextPopoverEntities}>
{isActiveContextLoading ? (
<div className={styles.contextPopoverEmpty}>
Loading {activeContextCategory.toLowerCase()}...
</div>
) : isActiveContextError ? (
<div className={styles.contextPopoverEmpty}>
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
) : (
filteredContextOptions.map((option) => {
const isSelected = selectedContexts.some(
(item) =>
item.category === activeContextCategory &&
item.entityId === option.id,
);
return (
<div
key={option.id}
className={cx(styles.contextPopoverEntityItem, {
[styles.selected]: isSelected,
})}
onClick={(): void =>
toggleContextSelection(
activeContextCategory,
option.id,
option.value,
)
}
>
<span className={styles.contextPopoverEntityItemText}>
{option.value}
</span>
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className={styles.rightActions}>
{isListening ? (
<div className={styles.micRecording}>
<div
className={cx(styles.micDiscard, styles.secondary)}
onClick={handleDiscard}
aria-label="Discard recording"
>
<X size={12} />
</div>
<span className={styles.micWaves} aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</span>
<div
className={cx(styles.micStop, styles.destructive)}
onClick={handleStopAndSend}
aria-label="Stop and send"
>
<Square size={9} fill="currentColor" strokeWidth={0} />
</div>
</div>
) : (
<Tooltip
title={
!isSupported
? 'Voice input not supported in this browser'
: 'Voice input'
}
>
<Button
variant="ghost"
size="icon"
onClick={start}
disabled={disabled || !isSupported}
aria-label="Start voice input"
className={styles.micBtn}
>
<Mic size={14} />
</Button>
</Tooltip>
)}
{isStreaming && onCancel ? (
<Tooltip title="Stop generating">
<Button
variant="solid"
size="icon"
color="destructive"
onClick={onCancel}
aria-label="Stop generating"
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
) : (
<Button
variant="solid"
size="icon"
color="primary"
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<Send size={14} />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,135 +0,0 @@
$radius: 4px;
.clarification {
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 10px 12px;
background: var(--l2-background);
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
&.submitted {
border-color: var(--l2-border);
background: transparent;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.headerIcon {
flex-shrink: 0;
color: var(--primary, var(--bg-robin-500));
}
.headerLabel {
font-size: 12px;
font-weight: 600;
color: var(--l1-foreground);
}
.message {
font-size: 13px;
color: var(--l1-foreground);
margin: 0;
line-height: 1.5;
}
.icon {
color: var(--bg-forest-500, #22c55e);
flex-shrink: 0;
}
.statusText {
font-size: 13px;
color: var(--l2-foreground);
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.required {
color: var(--bg-cherry-500, #ef4444);
margin-left: 2px;
}
.input,
.select {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 5px 8px;
font-size: 13px;
color: var(--l1-foreground);
outline: none;
transition: border-color 0.12s;
&:focus {
border-color: var(--primary, var(--bg-robin-500));
}
}
.select {
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;
flex-direction: column;
gap: 5px;
}
.radioLabel,
.checkboxLabel {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--l1-foreground);
cursor: pointer;
}
.radio,
.checkbox {
accent-color: var(--primary, var(--bg-robin-500));
cursor: pointer;
}
.actions {
display: flex;
gap: 6px;
margin-top: 16px;
}

View File

@@ -1,378 +0,0 @@
import { useState } from 'react';
import cx from 'classnames';
import {
Button,
Checkbox,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui';
import type {
ClarificationEventDTO,
ClarificationFieldEventDTO,
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { CircleHelp, Send, X } from '@signozhq/icons';
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;
}
/**
* Rendered when the agent emits a `clarification` SSE event.
* Dynamically renders form fields based on the `fields` array and
* submits answers to resume the agent on a new execution.
*/
export default function ClarificationForm({
conversationId,
clarification,
}: ClarificationFormProps): JSX.Element {
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const fields = clarification.fields ?? [];
const initialAnswers = Object.fromEntries(
fields.map((f) => [f.id, f.default ?? '']),
);
const [answers, setAnswers] =
useState<Record<string, unknown>>(initialAnswers);
const [submitted, setSubmitted] = useState(false);
const [cancelled, setCancelled] = useState(false);
const setField = (id: string, value: unknown): void => {
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const handleSubmit = async (): Promise<void> => {
setSubmitted(true);
await submitClarification(
conversationId,
clarification.clarificationId,
answers,
);
};
const handleCancel = (): void => {
setCancelled(true);
cancelStream(conversationId);
};
if (submitted) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<Send size={13} className={styles.icon} />
<span className={styles.statusText}>Answers submitted resuming</span>
</div>
);
}
if (cancelled) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<X size={13} className={styles.icon} />
<span className={styles.statusText}>Request cancelled.</span>
</div>
);
}
return (
<div className={styles.clarification}>
<div className={styles.header}>
<CircleHelp size={13} className={styles.headerIcon} />
<span className={styles.headerLabel}>A few details needed</span>
</div>
<p className={styles.message}>{clarification.message}</p>
<div className={styles.fields}>
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={answers[field.id]}
onChange={(val): void => setField(field.id, val)}
/>
))}
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isStreaming}
prefix={<Send />}
>
Submit
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancel}
disabled={isStreaming}
prefix={<X />}
>
Cancel request
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Field renderer — handles text, number, select, radio, checkbox
// ---------------------------------------------------------------------------
interface FieldInputProps {
field: ClarificationFieldEventDTO;
value: unknown;
onChange: (value: unknown) => void;
}
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
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);
}
};
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && <span className={styles.required}>*</span>}
</label>
<Select
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
onChange={handleSelectChange}
>
<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>
);
}
// 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>
<RadioGroup
name={id}
value={radioValue}
onChange={handleRadioChange}
className={styles.radioGroup}
>
{options?.map((opt) => (
<RadioGroupItem
key={opt}
value={opt}
containerClassName={styles.radioLabel}
>
{opt}
</RadioGroupItem>
))}
{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>
);
}
// 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[]) : [];
// 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(
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}>
{label}
{required && <span className={styles.required}>*</span>}
</span>
<div className={styles.checkboxGroup}>
{options?.map((opt) => (
<Checkbox
key={opt}
className={styles.checkboxLabel}
value={regularSelected.includes(opt)}
onChange={(): void => toggleRegular(opt)}
>
{opt}
</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>
);
}
// text / number (default)
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && <span className={styles.required}>*</span>}
</label>
<Input
id={id}
type={type === 'number' ? 'number' : 'text'}
className={styles.input}
value={String(value ?? '')}
onChange={(e): void =>
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
}
placeholder={label}
/>
</div>
);
}

View File

@@ -1,113 +0,0 @@
$radius: 4px;
.item {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border-radius: $radius;
cursor: pointer;
min-width: 0;
position: relative;
// Driven below: hover and active reveal the action buttons.
--actions-opacity: 0;
&:hover {
background: var(--l2-background);
--actions-opacity: 1;
}
&.active {
background: var(--l2-background);
--actions-opacity: 1;
.title {
color: var(--l1-foreground);
font-weight: 500;
}
}
&.archived {
opacity: 0.92;
.title {
color: var(--l3-foreground, var(--l2-foreground));
}
}
}
.icon {
flex-shrink: 0;
color: var(--l3-foreground, var(--l2-foreground));
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.time {
font-size: 10px;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
}
.input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--primary, var(--bg-robin-500));
outline: none;
color: var(--l1-foreground);
font-size: 12px;
font-family: inherit;
padding: 1px 0;
}
.actions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
opacity: var(--actions-opacity, 0);
transition: opacity 0.12s;
// Float over the right edge of the item so the title can keep using
// the full width while the buttons are hidden — no layout shift +
// no premature truncation. The `background` matches the hover/active
// state so the buttons visually mask any title text underneath.
position: absolute;
top: 50%;
right: 4px;
transform: translateY(-50%);
background: var(--l2-background);
padding: 1px 2px;
border-radius: $radius;
pointer-events: var(--actions-pointer, none);
}
.item:hover,
.item.active {
--actions-pointer: auto;
}
.btn {
padding: 2px !important;
height: auto !important;
min-height: 0 !important;
&.danger:hover {
color: var(--bg-cherry-500, #ef4444) !important;
}
}

View File

@@ -1,193 +0,0 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { Button, Input, Tooltip } from '@signozhq/ui';
import {
Archive,
ArchiveRestore,
MessageSquare,
Pencil,
} from '@signozhq/icons';
import { Conversation } from '../types';
import styles from './ConversationItem.module.scss';
interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
onRestore: (id: string) => void;
}
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60_000);
if (mins < 1) {
return 'just now';
}
if (mins < 60) {
return `${mins}m ago`;
}
const hrs = Math.floor(mins / 60);
if (hrs < 24) {
return `${hrs}h ago`;
}
const days = Math.floor(hrs / 24);
if (days < 7) {
return `${days}d ago`;
}
return new Date(ts).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
export default function ConversationItem({
conversation,
isActive,
onSelect,
onRename,
onDelete,
onRestore,
}: ConversationItemProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isArchived = Boolean(conversation.archived);
const displayTitle = conversation.title ?? 'New conversation';
const ts = conversation.updatedAt ?? conversation.createdAt;
const startEditing = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setEditValue(conversation.title ?? '');
setIsEditing(true);
},
[conversation.title],
);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
const commitEdit = useCallback(() => {
onRename(conversation.id, editValue);
setIsEditing(false);
}, [conversation.id, editValue, onRename]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
commitEdit();
}
if (e.key === 'Escape') {
setIsEditing(false);
}
},
[commitEdit],
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(conversation.id);
},
[conversation.id, onDelete],
);
const handleRestore = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRestore(conversation.id);
},
[conversation.id, onRestore],
);
const itemClass = cx(styles.item, {
[styles.active]: isActive,
[styles.archived]: isArchived,
});
return (
<div
className={itemClass}
onClick={(): void => onSelect(conversation.id)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(conversation.id);
}
}}
>
<MessageSquare size={12} className={styles.icon} />
<div className={styles.body}>
{isEditing ? (
<Input
ref={inputRef}
className={styles.input}
value={editValue}
onChange={(e): void => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitEdit}
onClick={(e): void => e.stopPropagation()}
maxLength={80}
/>
) : (
<>
<span className={styles.title} title={displayTitle}>
{displayTitle}
</span>
<span className={styles.time}>{formatRelativeTime(ts)}</span>
</>
)}
</div>
{!isEditing && (
<div className={styles.actions}>
<Tooltip title="Rename">
<Button
variant="link"
size="icon"
color="primary"
className={styles.btn}
onClick={startEditing}
aria-label="Rename conversation"
prefix={<Pencil size={11} />}
/>
</Tooltip>
{isArchived ? (
<Tooltip title="Restore to conversations">
<Button
variant="link"
size="icon"
color="primary"
onClick={handleRestore}
aria-label="Restore conversation"
prefix={<ArchiveRestore size={11} />}
/>
</Tooltip>
) : (
<Tooltip title="Archive">
<Button
variant="link"
size="icon"
color="destructive"
onClick={handleDelete}
aria-label="Archive conversation"
prefix={<Archive size={11} />}
/>
</Tooltip>
)}
</div>
)}
</div>
);
}

View File

@@ -1,90 +0,0 @@
$radius: 4px;
.thread {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 16px 0;
gap: 14px;
}
.message {
display: flex;
padding: 0 16px;
&.compact {
padding: 0 12px;
}
}
.user {
justify-content: flex-end;
}
.assistant {
justify-content: flex-start;
}
.bubble {
display: flex;
flex-direction: column;
gap: 8px;
// `width: 100%` (capped by per-role max-width below) forces the bubble
// to fill its allotted slot rather than collapsing to the longest line —
// otherwise the lines' percent widths cascade into a tiny bubble.
width: 100%;
border-radius: $radius;
padding: 12px 14px;
&.user {
// Narrower than the assistant bubble so the alternating chat-thread
// asymmetry is preserved — but wider than the previous 80% so the
// shimmer lines have room to read as a real-looking message.
max-width: 75%;
// Subtle primary tint so the right-side bubble reads as the user's
// message without committing to the full saturated brand color.
background: color-mix(
in srgb,
var(--primary, var(--bg-robin-500)) 18%,
transparent
);
border-bottom-right-radius: $radius;
color: var(--l1-foreground);
}
&.assistant {
max-width: 95%;
background: var(--l2-background);
border-bottom-left-radius: $radius;
}
}
.line {
height: 9px;
border-radius: 3px;
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
// Shimmer sweep — same pattern used by HistorySidebar's skeleton rows.
&::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--l1-foreground) 10%, transparent),
transparent
);
animation: shimmer 1.15s ease-in-out infinite;
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}

View File

@@ -1,53 +0,0 @@
import cx from 'classnames';
import { useVariant } from '../VariantContext';
import styles from './ConversationSkeleton.module.scss';
/**
* Each entry is one bubble in the placeholder thread:
* role: who "sent" the bubble — drives left/right alignment + colour
* lines: list of widths (as % of the bubble) for the shimmer lines inside
*
* Mixed widths and varying line counts produce something that scans as a real
* back-and-forth conversation rather than a uniform grid.
*/
const ROWS: { role: 'user' | 'assistant'; lines: number[] }[] = [
{ role: 'user', lines: [62] },
{ role: 'assistant', lines: [85, 92, 70] },
{ role: 'user', lines: [55, 40] },
{ role: 'assistant', lines: [90, 78, 95, 60] },
{ role: 'user', lines: [48] },
{ role: 'assistant', lines: [80, 88] },
];
/** Skeleton chat thread shown while a single conversation is being loaded. */
export default function ConversationSkeleton(): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
return (
<div className={styles.thread} aria-busy aria-label="Loading conversation">
{ROWS.map((row, idx) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={idx}
className={cx(styles.message, styles[row.role], {
[styles.compact]: isCompact,
})}
>
<div className={cx(styles.bubble, styles[row.role])}>
{row.lines.map((width, lineIdx) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={lineIdx}
className={styles.line}
style={{ width: `${width}%` }}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -1,125 +0,0 @@
@use '../_scrollbar' as *;
$radius: 4px;
.history {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// Page variant: fixed-width left column.
&.variantPage {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
}
// Panel variant: full-width overlay (replaces conversation view).
&.variantPanel {
width: 100%;
flex: 1;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px 8px;
flex-shrink: 0;
}
.heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l2-foreground);
// Collapse the line-box to the glyph height so the loading dots
// (centered against the line-box) line up with the cap-height of the
// uppercase text instead of sitting visually low.
line-height: 1;
}
.list {
position: relative;
flex: 1;
overflow-y: auto;
padding: 0 6px 12px;
@include scrollbar(0.25rem);
}
.empty {
margin: 20px 8px 0;
font-size: 12px;
color: var(--l3-foreground, var(--l2-foreground));
text-align: center;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.loadingDots {
display: inline-flex;
align-items: center;
gap: 3px;
}
.loadingDot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--l3-foreground, var(--l2-foreground));
opacity: 0.4;
animation: historyLoadingDot 1.1s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.18s;
}
&:nth-child(3) {
animation-delay: 0.36s;
}
}
@keyframes historyLoadingDot {
0%,
100% {
opacity: 0.25;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(-1px);
}
}
.group {
margin-bottom: 8px;
&.archived {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--l2-border);
}
}
.groupLabel {
display: block;
padding: 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--l3-foreground, var(--l2-foreground));
}

View File

@@ -1,182 +0,0 @@
import { useEffect, useMemo } from 'react';
import cx from 'classnames';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { Conversation } from '../types';
import { useVariant } from '../VariantContext';
import ConversationItem from './ConversationItem';
import styles from './HistorySidebar.module.scss';
interface HistorySidebarProps {
/** Called when a conversation is selected — lets the parent navigate if needed */
onSelect?: (id: string) => void;
}
function groupByDate(
conversations: Conversation[],
): { label: string; items: Conversation[] }[] {
const now = Date.now();
const DAY = 86_400_000;
const groups: Record<string, Conversation[]> = {
Today: [],
Yesterday: [],
'Last 7 days': [],
'Last 30 days': [],
Older: [],
};
for (const conv of conversations) {
const age = now - (conv.updatedAt ?? conv.createdAt);
if (age < DAY) {
groups.Today.push(conv);
} else if (age < 2 * DAY) {
groups.Yesterday.push(conv);
} else if (age < 7 * DAY) {
groups['Last 7 days'].push(conv);
} else if (age < 30 * DAY) {
groups['Last 30 days'].push(conv);
} else {
groups.Older.push(conv);
}
}
return Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
/**
* Three-dot loading indicator. Sits inside the sidebar header so the
* conversation list is never bumped down by a skeleton row when threads
* load — visible signal of in-flight work without any layout shift.
*/
function HeaderLoadingDots(): JSX.Element {
return (
<span className={styles.loadingDots} role="status" aria-label="Loading">
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
</span>
);
}
export default function HistorySidebar({
onSelect,
}: HistorySidebarProps): JSX.Element {
const variant = useVariant();
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
const setActiveConversation = useAIAssistantStore(
(s) => s.setActiveConversation,
);
const loadThread = useAIAssistantStore((s) => s.loadThread);
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
const deleteConversation = useAIAssistantStore((s) => s.deleteConversation);
const restoreConversation = useAIAssistantStore((s) => s.restoreConversation);
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
// Fetch threads from backend on mount
useEffect(() => {
void fetchThreads();
}, [fetchThreads]);
const sortedActive = useMemo(
() =>
Object.values(conversations)
.filter((c) => !c.archived)
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
[conversations],
);
const sortedArchived = useMemo(
() =>
Object.values(conversations)
.filter((c) => Boolean(c.archived) && c.threadId)
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
[conversations],
);
const groups = useMemo(() => groupByDate(sortedActive), [sortedActive]);
const hasAnySidebarRows = groups.length > 0 || sortedArchived.length > 0;
const handleSelect = (id: string): void => {
const conv = conversations[id];
if (conv?.threadId) {
// Always load from backend — refreshes messages and reconnects
// to active execution if the thread is still busy.
void loadThread(conv.threadId);
} else {
// Local-only conversation (no backend thread yet)
setActiveConversation(id);
}
onSelect?.(id);
};
const variantClass =
variant === 'page' ? styles.variantPage : styles.variantPanel;
return (
<div className={cx(styles.history, variantClass)}>
<div className={styles.header}>
<span className={styles.heading}>Conversations</span>
{isLoadingThreads && <HeaderLoadingDots />}
</div>
<div className={styles.list} aria-busy={isLoadingThreads}>
{isLoadingThreads && (
<span className={styles.srOnly} role="status">
Loading conversations
</span>
)}
{!isLoadingThreads && !hasAnySidebarRows && (
<p className={styles.empty}>No conversations yet.</p>
)}
{groups.map(({ label, items }) => (
<div key={label} className={styles.group}>
<span className={styles.groupLabel}>{label}</span>
{items.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
onRename={renameConversation}
onDelete={deleteConversation}
onRestore={restoreConversation}
/>
))}
</div>
))}
{sortedArchived.length > 0 && (
<div className={cx(styles.group, styles.archived)}>
<span className={styles.groupLabel}>Archived Conversations</span>
{sortedArchived.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
onRename={renameConversation}
onDelete={deleteConversation}
onRestore={restoreConversation}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,326 +0,0 @@
$radius: 4px;
.message {
display: flex;
padding: 8px 16px;
// CSS variable consumed by MessageFeedback to fade in on hover.
--feedback-opacity: 0;
&:hover {
--feedback-opacity: 1;
}
&.compact {
padding: 6px 12px;
}
}
.user {
justify-content: flex-end;
}
.assistant {
justify-content: flex-start;
}
.body {
display: flex;
flex-direction: column;
max-width: 80%;
&.compact {
max-width: 90%;
}
.user & {
align-items: flex-end;
}
.assistant & {
align-items: flex-start;
}
}
.bubble {
border-radius: $radius;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 153.846% */
letter-spacing: -0.065px;
max-width: 100%;
.user & {
background: var(--primary, var(--bg-robin-500));
color: #fff;
border-bottom-right-radius: $radius;
}
.assistant & {
// Flex column so text blocks, tool steps and cards space evenly.
// 14px reads as a clear "section break" between the prose and the
// auxiliary tool-call rows; consecutive tool calls reclaim some of
// that gap via a negative margin in `ToolCallStep.module.scss`.
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
background: var(--l2-background);
color: var(--l1-foreground);
border-bottom-left-radius: $radius;
}
}
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
// User-bubble row: pencil button sits to the LEFT of the bubble within
// the right-aligned message line, so it visually "ends" at the bubble's
// right edge while keeping the bubble in its original position.
.bubbleRow {
display: flex;
align-items: center;
gap: 2px;
max-width: 100%;
}
.editBtn {
flex-shrink: 0;
height: auto !important;
min-height: 0 !important;
padding: 4px !important;
color: var(--l3-foreground, var(--l2-foreground)) !important;
opacity: var(--feedback-opacity, 0);
transition: opacity 0.12s;
}
.markdown {
width: 100%;
word-break: break-word;
p {
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
}
ul,
ol {
margin: 0 0 0.65em;
padding-left: 1.5em;
&:last-child {
margin-bottom: 0;
}
}
li {
margin-bottom: 0.3em;
&:last-child {
margin-bottom: 0;
}
ul,
ol {
margin-top: 0.25em;
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.35;
margin: 0.9em 0 0.4em;
color: var(--l1-foreground);
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
h1 {
font-size: 1.15em;
}
h2 {
font-size: 1.08em;
}
h3 {
font-size: 1em;
}
h4,
h5,
h6 {
font-size: 0.95em;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
a {
color: var(--primary, var(--bg-robin-500));
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
opacity: 0.8;
}
}
blockquote {
border-left: 3px solid var(--l2-border);
padding: 0.1em 0 0.1em 0.8em;
color: var(--l2-foreground);
font-style: italic;
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
p {
margin-bottom: 0;
}
}
hr {
border: none;
border-top: 1px solid var(--l2-border);
margin: 0.75em 0;
}
code {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 11.5px;
border-radius: $radius;
padding: 1px 4px;
background: var(--l3-background);
color: var(--l1-foreground);
}
pre {
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
}
pre code {
display: block;
padding: 10px;
overflow-x: auto;
border-radius: $radius;
white-space: pre;
}
table {
border-collapse: collapse;
font-size: 12px;
margin: 0 0 0.65em;
width: 100%;
&:last-child {
margin-bottom: 0;
}
th,
td {
padding: 5px 10px;
border: 1px solid var(--l2-border);
text-align: left;
}
th {
background: var(--l2-background);
color: var(--l1-foreground);
font-weight: 600;
}
td {
color: var(--l1-foreground);
}
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.attachmentImage {
max-width: 200px;
max-height: 160px;
border-radius: $radius;
object-fit: cover;
}
.attachmentFile {
font-size: 11px;
padding: 3px 8px;
border-radius: $radius;
background: var(--l3-background);
color: var(--l2-foreground);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typingIndicator {
display: inline-flex;
align-items: center;
gap: 4px;
span {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--l2-foreground);
animation: bounce 1.2s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}

View File

@@ -1,185 +0,0 @@
import React from 'react';
import cx from 'classnames';
import { Button, Tooltip } from '@signozhq/ui';
import { Pencil } from '@signozhq/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Side-effect: registers all built-in block types into the BlockRegistry
import './blocks';
import { useVariant } from '../VariantContext';
import { Message, MessageBlock } from '../types';
import ActionsSection from './ActionsSection';
import { RichCodeBlock } from './blocks';
import { MessageContext } from './MessageContext';
import MessageFeedback from './MessageFeedback';
import ThinkingStep from './ThinkingStep';
import ToolCallStep from './ToolCallStep';
import styles from './MessageBubble.module.scss';
/**
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
* When RichCodeBlock replaces <code> with a custom AI block component, the
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
* This renderer detects that case and unwraps the <pre>.
*/
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
// If the code component returned something other than a <code> element
// (i.e. a custom AI block), render without the <pre> wrapper.
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
}
}
interface MessageBubbleProps {
message: Message;
onRegenerate?: () => void;
/**
* When provided, a Pencil icon appears on user messages. Clicking it
* pushes the message content into the chat input textarea so the user
* can tweak it and send it as a fresh message (the original stays in
* history). The actual submit happens via the input bar's Send button.
*/
onEdit?: (text: string) => void;
isLastAssistant?: boolean;
}
export default function MessageBubble({
message,
onRegenerate,
onEdit,
isLastAssistant = false,
}: MessageBubbleProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
const canEdit = isUser && Boolean(onEdit);
const messageClass = cx(
styles.message,
isUser ? styles.user : styles.assistant,
{
[styles.compact]: isCompact,
},
);
const bodyClass = cx(styles.body, { [styles.compact]: isCompact });
return (
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
<div className={bodyClass}>
<div className={styles.bubbleRow}>
<div className={styles.bubble}>
{message.attachments && message.attachments.length > 0 && (
<div className={styles.attachments}>
{message.attachments.map((att) => {
const isImage = att.type.startsWith('image/');
return isImage ? (
<img
key={att.name}
src={att.dataUrl}
alt={att.name}
className={styles.attachmentImage}
/>
) : (
<div key={att.name} className={styles.attachmentFile}>
{att.name}
</div>
);
})}
</div>
)}
{isUser ? (
<p className={styles.text}>{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>
<ReactMarkdown
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{message.content}
</ReactMarkdown>
</MessageContext.Provider>
)}
{!isUser && message.actions && message.actions.length > 0 && (
<ActionsSection actions={message.actions} />
)}
</div>
{isUser && canEdit && (
<Tooltip title="Edit and resend">
<Button
variant="link"
size="icon"
color="secondary"
className={styles.editBtn}
onClick={(): void => onEdit?.(message.content)}
aria-label="Edit and resend message"
prefix={<Pencil size={10} />}
/>
</Tooltip>
)}
</div>
{!isUser && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}
isLastAssistant={isLastAssistant}
/>
)}
</div>
</div>
);
}

View File

@@ -1,13 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
interface MessageContextValue {
messageId: string;
}
export const MessageContext = createContext<MessageContextValue>({
messageId: '',
});
export const useMessageContext = (): MessageContextValue =>
useContext(MessageContext);

View File

@@ -1,55 +0,0 @@
.feedback {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 2px 0;
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover/visible).
opacity: var(--feedback-opacity, 0);
transition: opacity 0.15s ease;
&.visible {
--feedback-opacity: 1;
opacity: 1;
}
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.btn {
width: 24px !important;
height: 24px !important;
min-height: 0 !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: var(--l3-foreground, #6b7280) !important;
&:hover {
color: var(--l1-foreground) !important;
}
&.active {
color: var(--bg-forest-500, #22c55e) !important;
}
&.votedUp {
color: var(--primary, var(--bg-robin-500)) !important;
}
&.votedDown {
color: var(--bg-cherry-500, #ef4444) !important;
}
}
.time {
font-size: 10px;
color: var(--l3-foreground, #6b7280);
white-space: nowrap;
padding-left: 2px;
border-left: 1px solid var(--l2-border);
}

View File

@@ -1,157 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { useCopyToClipboard } from 'react-use';
import { Button, Tooltip } from '@signozhq/ui';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { FeedbackRating, Message } from '../types';
import styles from './MessageFeedback.module.scss';
interface MessageFeedbackProps {
message: Message;
onRegenerate?: () => void;
isLastAssistant?: boolean;
}
function formatRelativeTime(timestamp: number): string {
const diffMs = Date.now() - timestamp;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 10) {
return 'just now';
}
if (diffSec < 60) {
return `${diffSec}s ago`;
}
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
}
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) {
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
}
const diffDay = Math.floor(diffHr / 24);
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
}
export default function MessageFeedback({
message,
onRegenerate,
isLastAssistant = false,
}: MessageFeedbackProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Local vote state — initialised from persisted feedbackRating, updated
// immediately on click so the UI responds without waiting for the API.
const [vote, setVote] = useState<FeedbackRating | null>(
message.feedbackRating ?? null,
);
const [relativeTime, setRelativeTime] = useState(() =>
formatRelativeTime(message.createdAt),
);
const absoluteTime = useMemo(
() =>
formatTimezoneAdjustedTimestamp(
message.createdAt,
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
),
[message.createdAt, formatTimezoneAdjustedTimestamp],
);
// Tick relative time every 30 s
useEffect(() => {
const id = setInterval(() => {
setRelativeTime(formatRelativeTime(message.createdAt));
}, 30_000);
return (): void => clearInterval(id);
}, [message.createdAt]);
const handleCopy = useCallback((): void => {
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
if (vote === rating) {
return;
}
setVote(rating);
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback],
);
return (
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={cx(styles.btn, { [styles.active]: copied })}
size="icon"
variant="ghost"
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
<Tooltip title="Good response">
<Button
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
size="icon"
variant="ghost"
onClick={(): void => handleVote('positive')}
>
<ThumbsUp size={12} />
</Button>
</Tooltip>
<Tooltip title="Bad response">
<Button
className={cx(styles.btn, { [styles.votedDown]: vote === 'negative' })}
size="icon"
variant="ghost"
onClick={(): void => handleVote('negative')}
>
<ThumbsDown size={12} />
</Button>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate">
<Button
className={styles.btn}
size="icon"
variant="ghost"
onClick={onRegenerate}
>
<RefreshCw size={12} />
</Button>
</Tooltip>
)}
</div>
<span className={styles.time}>
{relativeTime} · {absoluteTime}
</span>
</div>
);
}

View File

@@ -1,9 +0,0 @@
.streamingStatus {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-style: italic;
color: var(--l3-foreground, #6b7280);
margin-bottom: 6px;
}

View File

@@ -1,132 +0,0 @@
import React from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type {
ApprovalEventDTO,
ClarificationEventDTO,
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import { useVariant } from '../VariantContext';
import { StreamingEventItem } from '../types';
import ApprovalCard from './ApprovalCard';
import { RichCodeBlock } from './blocks';
import ClarificationForm from './ClarificationForm';
import ThinkingStep from './ThinkingStep';
import ToolCallStep from './ToolCallStep';
import messageStyles from './MessageBubble.module.scss';
import styles from './StreamingMessage.module.scss';
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
running: 'Thinking…',
awaiting_approval: 'Waiting for your approval…',
awaiting_clarification: 'Waiting for your input…',
resumed: 'Resumed…',
};
function TypingDots(): JSX.Element {
return (
<span className={messageStyles.typingIndicator}>
<span />
<span />
<span />
</span>
);
}
interface StreamingMessageProps {
conversationId: string;
/** Ordered timeline of text and tool-call events in arrival order. */
events: StreamingEventItem[];
status?: string;
pendingApproval?: ApprovalEventDTO | null;
pendingClarification?: ClarificationEventDTO | null;
}
export default function StreamingMessage({
conversationId,
events,
status = '',
pendingApproval = null,
pendingClarification = null,
}: StreamingMessageProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const statusLabel = STATUS_LABEL[status] ?? '';
const isEmpty =
events.length === 0 && !pendingApproval && !pendingClarification;
const isWaitingOnUser = Boolean(pendingApproval || pendingClarification);
const messageClass = cx(messageStyles.message, messageStyles.assistant, {
[messageStyles.compact]: isCompact,
});
return (
<div className={messageClass}>
<div className={messageStyles.bubble}>
{/* Pre-output indicator — only before any events arrive. */}
{isEmpty && statusLabel && (
<span className={styles.streamingStatus}>{statusLabel}</span>
)}
{isEmpty && !statusLabel && <TypingDots />}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
}
return (
<ReactMarkdown
key={i}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* While events are still streaming, append the typing dots so the
user has a clear "more is coming" signal. Hidden when the agent
is waiting on the user's input (an approval or clarification
card already conveys that state). */}
{!isEmpty && !isWaitingOnUser && <TypingDots />}
{/* Approval / clarification cards appended after any streamed text */}
{pendingApproval && (
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
)}
{pendingClarification && (
<ClarificationForm
conversationId={conversationId}
clarification={pendingClarification}
/>
)}
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
// Thinking blocks share the same de-emphasised "activity log" treatment as
// tool calls — slim row, left rail, no border or surface. See
// `ToolCallStep.module.scss` for the rationale.
.step {
width: 100%;
font-size: 11px;
border-left: 2px solid var(--l2-border);
padding-left: 8px;
// Tighten consecutive thinking rows so they read as one activity group.
& + & {
margin-top: -6px;
}
}
.header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 2px 0;
background: transparent;
border: none;
cursor: pointer;
font-size: 10px;
color: var(--l3-foreground, #6b7280);
font-style: italic;
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.icon {
flex-shrink: 0;
color: var(--l3-foreground, #6b7280);
}
.label {
font-weight: 500;
}
.chevron {
flex-shrink: 0;
color: var(--l3-foreground, #6b7280);
margin-left: auto;
}
.body {
margin-top: 4px;
padding: 4px 0;
}
.content {
font-size: 11px;
color: var(--l3-foreground, #6b7280);
font-style: italic;
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}

View File

@@ -1,42 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui';
import { Brain, ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
}
/** Displays a collapsible thinking/reasoning block. */
export default function ThinkingStep({
content,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
return (
<div className={styles.step}>
<Button
variant="ghost"
color="secondary"
className={styles.header}
onClick={(): void => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Brain size={12} className={styles.icon} />
<span className={styles.label}>Thinking</span>
{expanded ? (
<ChevronDown size={11} className={styles.chevron} />
) : (
<ChevronRight size={11} className={styles.chevron} />
)}
</Button>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
</div>
);
}

View File

@@ -1,136 +0,0 @@
$radius: 4px;
// Tool calls are auxiliary "what the agent did" rows. Keep them visually
// secondary so the prose in the bubble stays the focal point: no border, no
// box background, faded text, slim padding, and a thin left rail to group
// them visually as a sequence.
.step {
width: 100%;
font-size: 11px;
border-left: 2px solid var(--l2-border);
padding-left: 8px;
&.running {
opacity: 0.85;
}
// Consecutive tool-call rows belong to one activity group — pull them
// closer together and let the bubble's gap (10px) do the work between
// the group and surrounding markdown / blocks.
& + & {
margin-top: -6px;
}
}
.header {
// Plain interactive div (role="button") — owns all of its own styling
// since it no longer wraps a design-system Button.
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 2px 0;
margin: 0;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
cursor: pointer;
text-align: left;
font: inherit;
color: var(--l3-foreground, #6b7280);
user-select: none;
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
background: transparent;
}
&:focus-visible {
outline: 2px solid var(--primary, var(--bg-robin-500));
outline-offset: 2px;
border-radius: 2px;
}
}
.icon {
flex-shrink: 0;
color: var(--l3-foreground, #6b7280);
&.spin {
color: var(--primary, var(--bg-robin-500));
animation: spin 1s linear infinite;
}
&.done {
color: var(--bg-forest-500, #22c55e);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.label {
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
color: var(--l3-foreground, #6b7280);
}
.body {
margin-top: 4px;
padding: 6px 0 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.section {
display: flex;
flex-direction: column;
gap: 3px;
}
.sectionLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l3-foreground, #6b7280);
}
.toolName {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.json {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--l2-foreground);
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 5px 7px;
margin: 0;
overflow-x: auto;
white-space: pre;
max-height: 160px;
}

View File

@@ -1,84 +0,0 @@
import { useState, KeyboardEvent } from 'react';
import cx from 'classnames';
import {
ChevronDown,
ChevronRight,
LoaderCircle,
Wrench,
} from '@signozhq/icons';
import { StreamingToolCall } from '../types';
import styles from './ToolCallStep.module.scss';
interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/** Displays a single tool invocation, collapsible, with in/out detail. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done } = toolCall;
// Format tool name: "signoz_get_dashboard" → "Get Dashboard"
const label = toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const toggle = (): void => setExpanded((v) => !v);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
return (
<div className={cx(styles.step, { [styles.running]: !done })}>
<div
className={styles.header}
onClick={toggle}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-expanded={expanded}
>
{done ? (
<Wrench size={12} className={cx(styles.icon, styles.done)} />
) : (
<LoaderCircle size={12} className={cx(styles.icon, styles.spin)} />
)}
<span className={styles.label}>{label}</span>
{expanded ? (
<ChevronDown size={11} className={styles.chevron} />
) : (
<ChevronRight size={11} className={styles.chevron} />
)}
</div>
{expanded && (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,89 +0,0 @@
@use '../_scrollbar' as *;
$radius: 4px;
.messages {
flex: 1;
overflow: auto;
@include scrollbar;
// 64px bottom padding leaves breathing room between the last bubble and
// the scroll viewport's edge so the bubble doesn't sit flush against the
// disclaimer / input bar. The scroll-to-bottom effect uses the scroller
// ref to scroll past this padding (Virtuoso's `align: 'end'` would only
// reach the last item's bottom and leave the padding hidden below).
& > div {
padding: 16px 0 64px;
}
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
gap: 8px;
text-align: center;
}
.emptyIcon {
margin-bottom: 4px;
opacity: 0.85;
}
.emptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--l1-foreground);
margin: 0;
}
.emptySubtitle {
font-size: 13px;
color: var(--l3-foreground, var(--l2-foreground));
margin: 0 0 12px;
max-width: 320px;
line-height: 1.45;
}
.emptySuggestions {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 360px;
}
.emptyChip {
display: flex;
align-items: center;
justify-content: flex-start !important;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--l2-border);
border-radius: $radius;
background: var(--l1-background);
color: var(--l2-foreground);
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.35;
&:hover {
background: var(--l2-background);
border-color: var(--l3-border, var(--l2-border));
color: var(--l1-foreground);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground, var(--l2-foreground));
}
&:hover svg {
color: var(--primary, var(--bg-robin-500));
}
}

View File

@@ -1,199 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@signozhq/ui';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import {
Activity,
TriangleAlert,
ChartBar,
Search,
Zap,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../types';
import AIAssistantIcon from './AIAssistantIcon';
import MessageBubble from './MessageBubble';
import StreamingMessage from './StreamingMessage';
import styles from './VirtualizedMessages.module.scss';
const SUGGESTIONS = [
{
icon: TriangleAlert,
text: 'Show me the top errors in the last hour',
},
{
icon: Activity,
text: 'What services have the highest latency?',
},
{
icon: ChartBar,
text: 'Give me an overview of system health',
},
{
icon: Search,
text: 'Find slow database queries',
},
{
icon: Zap,
text: 'Which endpoints have the most 5xx errors?',
},
];
const EMPTY_EVENTS: StreamingEventItem[] = [];
interface VirtualizedMessagesProps {
conversationId: string;
messages: Message[];
isStreaming: boolean;
/** Click handler for the per-bubble "Edit and resend" action. */
onEditMessage?: (text: string) => void;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
onEditMessage,
}: VirtualizedMessagesProps): JSX.Element {
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
);
const streamingEvents = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
);
// Text deltas append into the last `streamingEvents` entry rather than
// pushing a new one, so `streamingEvents.length` doesn't grow as the
// assistant streams text. Tracking the content length gives us a per-chunk
// scroll trigger.
const streamingContentLength = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingContent.length ?? 0,
);
const pendingApproval = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarification = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement | Window | null>(null);
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
const handleRegenerate = useCallback((): void => {
if (lastUserMessage && !isStreaming) {
sendMessage(lastUserMessage.content, lastUserMessage.attachments);
}
}, [lastUserMessage, isStreaming, sendMessage]);
// Scroll all the way to the actual bottom — including the 64px of bottom
// padding on the scroller — so the last bubble has visible breathing room
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
// 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.
useEffect(() => {
const scroller = scrollerRef.current;
if (!(scroller instanceof HTMLElement)) {
return;
}
scroller.scrollTo({
top: scroller.scrollHeight,
behavior: isStreaming ? 'auto' : 'smooth',
});
}, [
messages.length,
streamingEvents.length,
streamingContentLength,
isStreaming,
pendingApproval,
pendingClarification,
]);
const followOutput = useCallback(
(atBottom: boolean): false | 'auto' | 'smooth' => {
if (isStreaming) {
return 'auto';
}
return atBottom ? 'smooth' : false;
},
[isStreaming],
);
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<AIAssistantIcon size={40} />
</div>
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>
<div className={styles.emptySuggestions}>
{SUGGESTIONS.map((s) => (
<Button
key={s.text}
variant="outlined"
color="secondary"
className={styles.emptyChip}
onClick={(): void => {
sendMessage(s.text);
}}
prefix={<s.icon size={14} />}
>
{s.text}
</Button>
))}
</div>
</div>
);
}
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
return (
<Virtuoso
ref={virtuosoRef}
scrollerRef={(ref): void => {
scrollerRef.current = ref;
}}
className={styles.messages}
totalCount={totalCount}
followOutput={followOutput}
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
itemContent={(index): JSX.Element => {
if (index < messages.length) {
const msg = messages[index];
const isLastAssistant =
msg.role === 'assistant' &&
messages.slice(index + 1).every((m) => m.role !== 'assistant');
return (
<MessageBubble
message={msg}
onRegenerate={
isLastAssistant && !showStreamingSlot ? handleRegenerate : undefined
}
onEdit={msg.role === 'user' && !isStreaming ? onEditMessage : undefined}
isLastAssistant={isLastAssistant}
/>
);
}
return (
<StreamingMessage
conversationId={conversationId}
events={streamingEvents}
status={streamingStatus}
pendingApproval={pendingApproval}
pendingClarification={pendingClarification}
/>
);
}}
/>
);
}

View File

@@ -1,104 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 6px;
}
.zapIcon {
color: var(--bg-amber-400, #fbbf24);
flex-shrink: 0;
}
.headerLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.description {
margin: 0 0 10px;
font-size: 13px;
color: var(--l1-foreground);
line-height: 1.5;
}
.params {
list-style: none;
margin: 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.param {
display: flex;
gap: 6px;
font-size: 12px;
line-height: 1.4;
}
.paramKey {
color: var(--l3-foreground, var(--l2-foreground));
flex-shrink: 0;
&::after {
content: ':';
}
}
.paramVal {
color: var(--l1-foreground);
font-family: var(--font-mono, monospace);
word-break: break-all;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Answered / terminal state container layout (composed with .block from Block.module.scss).
.applied,
.dismissed,
.loading,
.error {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
opacity: 0.8;
}
.statusIcon {
flex-shrink: 0;
&.ok {
color: var(--bg-forest-500, #22c55e);
}
&.no {
color: var(--l3-foreground, #6b7280);
}
&.err {
color: var(--bg-cherry-500, #ef4444);
}
}
.statusText {
font-size: 13px;
color: var(--l1-foreground);
}

View File

@@ -1,203 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui';
import { Check, LoaderCircle, TriangleAlert, X, Zap } from '@signozhq/icons';
import { PageActionRegistry } from '../../pageActions/PageActionRegistry';
import { AIActionBlock } from '../../pageActions/types';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { useMessageContext } from '../MessageContext';
import blockStyles from './Block.module.scss';
import styles from './ActionBlock.module.scss';
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
/**
* Renders an AI-suggested page action.
*
* Two modes based on the registered PageAction.autoApply flag:
*
* autoApply = false (default)
* Shows a confirmation card with Accept / Dismiss. The user must
* explicitly approve before execute() is called. Use for destructive or
* hard-to-reverse actions (create dashboard, delete alert, etc.).
*
* autoApply = true
* Executes immediately on mount — no card shown, just the result summary.
* Use for low-risk, reversible actions where the user explicitly asked for
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
*
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
*/
export default function ActionBlock({
data,
}: {
data: AIActionBlock;
}): JSX.Element {
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const [localState, setLocalState] = useState<BlockState>(() => {
if (!messageId) {
return 'pending';
}
const saved = answeredBlocks[messageId];
if (!saved) {
return 'pending';
}
if (saved === 'dismissed') {
return 'dismissed';
}
if (saved.startsWith('error:')) {
return 'error';
}
return 'applied';
});
const [resultSummary, setResultSummary] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const { actionId, description, parameters } = data;
// ── Shared execute logic ─────────────────────────────────────────────────────
const execute = async (): Promise<void> => {
const action = PageActionRegistry.get(actionId);
if (!action) {
const msg = `Action "${actionId}" is not available on the current page.`;
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
return;
}
setLocalState('loading');
try {
const result = await action.execute(parameters as never);
setResultSummary(result.summary);
setLocalState('applied');
if (messageId) {
markBlockAnswered(messageId, `applied:${result.summary}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
}
};
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
const autoApplyFired = useRef(false);
useEffect(() => {
// Only auto-apply once, and only when the block hasn't been answered yet
// (i.e. this is a fresh render, not a remount of an already-answered block).
if (autoApplyFired.current || localState !== 'pending') {
return;
}
const action = PageActionRegistry.get(actionId);
if (!action?.autoApply) {
return;
}
autoApplyFired.current = true;
execute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDismiss = (): void => {
setLocalState('dismissed');
if (messageId) {
markBlockAnswered(messageId, 'dismissed');
}
};
// ── Terminal states ──────────────────────────────────────────────────────────
if (localState === 'applied') {
return (
<div className={cx(blockStyles.block, styles.applied)}>
<Check size={13} className={cx(styles.statusIcon, styles.ok)} />
<span className={styles.statusText}>{resultSummary || 'Applied.'}</span>
</div>
);
}
if (localState === 'dismissed') {
return (
<div className={cx(blockStyles.block, styles.dismissed)}>
<X size={13} className={cx(styles.statusIcon, styles.no)} />
<span className={styles.statusText}>Dismissed.</span>
</div>
);
}
if (localState === 'error') {
return (
<div className={cx(blockStyles.block, styles.error)}>
<TriangleAlert size={13} className={cx(styles.statusIcon, styles.err)} />
<span className={styles.statusText}>{errorMessage}</span>
</div>
);
}
// ── Loading (autoApply in progress) ─────────────────────────────────────────
if (localState === 'loading') {
return (
<div className={cx(blockStyles.block, styles.loading)}>
<LoaderCircle size={13} className={cx(styles.spinner, styles.statusIcon)} />
<span className={styles.statusText}>{description}</span>
</div>
);
}
// ── Pending: manual confirmation card ────────────────────────────────────────
const paramEntries = Object.entries(parameters ?? {});
return (
<div className={blockStyles.block}>
<div className={styles.header}>
<Zap size={13} className={styles.zapIcon} />
<span className={styles.headerLabel}>Suggested Action</span>
</div>
<p className={styles.description}>{description}</p>
{paramEntries.length > 0 && (
<ul className={styles.params}>
{paramEntries.map(([key, val]) => (
<li key={key} className={styles.param}>
<span className={styles.paramKey}>{key}</span>
<span className={styles.paramVal}>
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</li>
))}
</ul>
)}
<div className={styles.actions}>
<Button variant="solid" size="sm" onClick={execute}>
<Check size={12} />
Apply
</Button>
<Button variant="outlined" size="sm" onClick={handleDismiss}>
<X size={12} />
Dismiss
</Button>
</div>
</div>
);
}

View File

@@ -1,126 +0,0 @@
import { Bar } from 'react-chartjs-2';
import blockStyles from './Block.module.scss';
import chartStyles from './Chart.module.scss';
import { CHART_PALETTE, getChartTheme } from './chartSetup';
export interface BarDataset {
label?: string;
data: number[];
color?: string;
}
export interface BarChartData {
title?: string;
unit?: string;
/**
* Category labels (x-axis for vertical, y-axis for horizontal).
* Shorthand: omit `datasets` and use `bars` for single-series data.
*/
labels?: string[];
datasets?: BarDataset[];
/** Single-series shorthand: [{ label, value }] */
bars?: { label: string; value: number; color?: string }[];
/** 'vertical' (default) | 'horizontal' */
direction?: 'vertical' | 'horizontal';
}
export default function BarChartBlock({
data,
}: {
data: BarChartData;
}): JSX.Element {
const { title, unit, direction = 'horizontal' } = data;
const theme = getChartTheme();
// Normalise shorthand `bars` → labels + datasets
let labels: string[];
let datasets: BarDataset[];
if (data.bars) {
labels = data.bars.map((b) => b.label);
datasets = [
{
label: title ?? 'Value',
data: data.bars.map((b) => b.value),
color: undefined, // use palette below
},
];
} else {
labels = data.labels ?? [];
datasets = data.datasets ?? [];
}
const chartData = {
labels,
datasets: datasets.map((ds, i) => {
const baseColor = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
return {
label: ds.label ?? `Series ${i + 1}`,
data: ds.data,
backgroundColor: data.bars
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
: baseColor,
borderColor: data.bars
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
: baseColor,
borderWidth: 1,
borderRadius: 3,
};
}),
};
const barHeight = Math.max(160, labels.length * 28 + 48);
return (
<div className={blockStyles.block}>
{title && <p className={blockStyles.title}>{title}</p>}
<div
className={chartStyles.canvasWrap}
style={{ height: direction === 'horizontal' ? barHeight : 200 }}
>
<Bar
data={chartData}
options={{
indexAxis: direction === 'horizontal' ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: datasets.length > 1,
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
},
tooltip: {
backgroundColor: theme.tooltipBg,
titleColor: theme.tooltipText,
bodyColor: theme.tooltipText,
borderColor: theme.gridColor,
borderWidth: 1,
callbacks: unit
? { label: (ctx): string => `${ctx.formattedValue} ${unit}` }
: {},
},
},
scales: {
x: {
grid: { color: theme.gridColor },
ticks: {
color: theme.tickColor,
font: { size: 11 },
callback:
unit && direction !== 'horizontal'
? (v): string => `${v} ${unit}`
: undefined,
},
},
y: {
grid: { color: theme.gridColor },
ticks: { color: theme.tickColor, font: { size: 11 } },
},
},
}}
/>
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
$radius: 4px;
.block {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: $radius;
padding: 12px 14px;
margin: 8px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.unit {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.empty {
margin: 0;
font-size: 12px;
color: var(--l3-foreground, var(--l2-foreground));
}

View File

@@ -1,36 +0,0 @@
import React from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
/**
* Global registry for AI response block renderers.
*
* Any part of the application can register a custom block type:
*
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
* BlockRegistry.register('my-panel', MyPanelComponent);
*
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
* JSON payload, and the registered component will be rendered in-place:
*
* ```ai-my-panel
* { "foo": "bar" }
* ```
*/
const _registry = new Map<string, BlockComponent>();
export const BlockRegistry = {
register<T>(type: string, component: BlockComponent<T>): void {
_registry.set(type, component as BlockComponent);
},
get(type: string): BlockComponent | undefined {
return _registry.get(type);
},
/** Returns all registered type names (useful for debugging). */
types(): string[] {
return Array.from(_registry.keys());
},
};

View File

@@ -1,8 +0,0 @@
.canvasWrap {
position: relative;
height: 200px;
&.pie {
height: 180px;
}
}

View File

@@ -1,36 +0,0 @@
.message {
margin: 0 0 10px;
font-size: 13px;
color: var(--l1-foreground);
line-height: 1.5;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.answered {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
opacity: 0.7;
}
.icon {
flex-shrink: 0;
&.ok {
color: var(--bg-forest-500, #22c55e);
}
&.no {
color: var(--bg-cherry-500, #ef4444);
}
}
.answerText {
font-size: 13px;
color: var(--l1-foreground);
}

Some files were not shown because too many files have changed in this diff Show More