Compare commits

...

57 Commits

Author SHA1 Message Date
Yunus M
3313fa43ba feat: enhance conversation item and history sidebar with search functionality and dropdown actions 2026-05-04 21:40:34 +05:30
Yunus M
fd17be12d1 refactor: update styles and structure for MessageBubble, ThinkingStep, and ToolCallStep components 2026-05-04 21:10:27 +05:30
Yunus M
c427bfaf10 refactor: remove AI backend URL from environment variables 2026-05-04 20:00:23 +05:30
Yunus M
02a5c50c74 Merge branch 'main' into feat/ai-assistant 2026-05-04 19:26:59 +05:30
Yunus M
613ebc325e refactor: remove edit and resend functionality, streamline message handling 2026-05-04 18:55:16 +05:30
Vinicius Lourenço
20dd264ac1 feat(infra-monitoring): use new table component (#11122)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(infra-monitoring): use new table component

* test(k8s-base-list): try fix issue with flaky test

* fix(table): tweaks in the layout

* fix(pr-comments): usage of const and move disable lint to line

* fix(css): format of css file

* test(k8s-base-list): flaky test

* test(k8s-base-list): second try to fix flaky test

* fix(table): have different ids for expanded table

* fix(k8s-base-list): third attempt to de-flaky test

* refactor(table): tiny adjustments on table

* fix(k8s-empty-state): better title size
2026-05-04 13:16:23 +00:00
Vishal Sharma
8a7793794d feat(global): add ai assistant url to global config (#11171) 2026-05-04 13:06:33 +00:00
Pandey
680bcd08c3 fix(types): correct OpenAPI schema for AuthDomainConfig and PostableChannel (#11164)
* fix(authtypes): embed values and expose AuthDomainConfig oneOf

GettableAuthDomain now embeds StorableAuthDomain and AuthDomainConfig
by value so the response flattens correctly. AuthDomainConfig also
implements jsonschema.OneOfExposer over the SAML/Google/OIDC variants.

* fix(alertmanagertypes): expose PostableChannel JSONSchema

PostableChannel now implements jsonschema.Exposer, requiring name
and a oneOf branch per *_configs field so the OpenAPI request body
for POST /channels matches the runtime contract enforced in
NewChannelFromReceiver. Switched the route's Request type from
Receiver to PostableChannel and regenerated the OpenAPI spec.

* fix(alertmanagertypes): use components/schemas prefix in PostableChannel refs

The standalone reflector inside JSONSchema defaulted to #/definitions/
prefix, producing dangling refs to ConfigDiscordConfig etc. that broke
the generated frontend client. Pass DefinitionsPrefix("#/components/schemas/")
so refs point to existing OpenAPI components, and regenerate the frontend
Orval client.

* feat(authdomain): add GET /api/v1/domains/{id} endpoint

Returns a single GettableAuthDomain scoped to the caller's organization,
backed by the existing module.GetByOrgIDAndID. Adds Get to the Handler
interface, wires the route under AdminAccess, and regenerates the
OpenAPI spec and frontend Orval client.

* feat(authtypes): expose AuthNProvider enum in OpenAPI schema

AuthNProvider now implements jsonschema.Enum, narrowing the generated
TypeScript type from string to a typed enum. Updated callers in the
auth-domain settings UI and mocks to use AuthtypesAuthNProviderDTO,
and added an early-return guard in the create/edit submit handler so
TS can narrow the union before passing it as ssoType.

* chore(types): document oneOf/discriminator mismatch on PostableChannel and AuthDomainConfig

Both types emit a oneOf in the OpenAPI spec but neither shape supports an
OpenAPI discriminator: PostableChannel implies the variant by which *_configs
field is present, and AuthDomainConfig keeps the variant payload in a
sibling field instead of being the payload itself. Leave a TODO pointing at
ruletypes.RuleThresholdData as the envelope pattern to migrate to.

* fix(ruletypes): handle string driver values in Schedule.Scan and Recurrence.Scan

The Scan methods only handled []byte and silently no-op'd on anything
else. SQLite's TEXT columns come back as string from the driver, so
every GET of a planned_maintenance returned a zero-valued Schedule
(empty timezone, 0001-01-01 startTime/endTime, no recurrence) — even
though Create + Update wrote the values correctly.

Switch on src type, accept []byte, string, and nil; error on anything
else. Aligns Schedule with the existing pattern; in Recurrence fixes
the receiver — Unmarshal was being passed src (the interface{} arg)
rather than r.
2026-05-04 18:00:43 +05:30
Vinicius Lourenço
5cf0e0fbb9 Reapply "feat(global-time-store): add support to context, url persistence, store persistence, drift handle (#11081)" (#11152) (#11157)
This reverts commit 8b13f004ed.
2026-05-04 11:04:26 +00:00
Yunus M
33c5239c6c feat: enhance AI Assistant components with new enums, improved clarification handling, code block 2026-05-03 15:31:15 +05:30
Yunus M
6bc04e98aa chore: update openapi.yaml with improved formatting and structure for API documentation 2026-05-02 19:59:20 +05:30
Yunus M
7e4eda39dc feat: add accessibility attributes and feedback buttons to HeaderRightSection 2026-05-02 19:52:03 +05:30
Yunus M
3d944fe064 feat: improve tool call instance rendering 2026-05-02 18:49:55 +05:30
Yunus M
999857cd01 feat: implement AI Assistant UI components for enhanced user interaction 2026-05-02 16:50:28 +05:30
Yunus M
9175356c46 Merge branch 'main' into feat/ai-assistant 2026-05-01 19:52:13 +05:30
Yunus M
808add5401 feat: add edit and resend functionality to user messages in ConversationView and ChatInput 2026-05-01 19:50:54 +05:30
Yunus M
3ac0c2f08a feat: implement push-to-talk functionality in ChatInput for improved voice interaction 2026-05-01 19:27:14 +05:30
Yunus M
df6aefa243 refactor: streamline Spinner component and update ChatInput styles for improved UI consistency 2026-05-01 19:09:44 +05:30
Yunus M
fd4f5f085b refactor: enhance AIAssistantModal and ConversationItem components with improved key handling and UI 2026-05-01 18:15:24 +05:30
Yunus M
292f99b922 Merge branch 'main' into feat/ai-assistant 2026-05-01 17:33:28 +05:30
Yunus M
46d630a38e refactor: enhance ChatInput component with improved overflow handling and context fetching logic 2026-05-01 17:31:10 +05:30
Yunus M
2df265abbf refactor: support auto-derived contexts and enhance diff display functionality 2026-05-01 15:39:01 +05:30
Yunus M
02a743f8ab refactor: implement AIAssistant axios instance and enhance SSE authentication handling 2026-04-30 21:52:33 +05:30
Yunus M
b8073ab33b refactor: enhance ConversationView loading state and improve action key stability 2026-04-30 19:32:06 +05:30
Yunus M
4ddebbaa07 refactor: improve loading indicators in HistorySidebar component 2026-04-30 18:36:53 +05:30
Yunus M
b55d1a2683 refactor: simplify action handling and enhance streaming message indicators 2026-04-30 18:31:36 +05:30
Yunus M
f11fb74584 refactor: update AIAssistant components to use Button from Signoz UI and enhance styling 2026-04-30 17:45:10 +05:30
Yunus M
4d91273d58 feat: enhance AI Assistant with new API integration and UI components 2026-04-30 17:18:19 +05:30
Yunus M
d6ec1295d7 feat: move to css modules 2026-04-30 14:41:40 +05:30
Yunus M
0dc1dfc1cb Merge branch 'main' into feat/ai-assistant 2026-04-29 18:20:33 +05:30
Yunus M
49e1b43317 feat: update AI Assistant styles and components to use new icon library and improve layout 2026-04-28 21:45:47 +05:30
Yunus M
a240aed898 feat: add MessageContext interface and enhance message sending functionality in AI Assistant 2026-04-28 21:19:00 +05:30
Yunus M
adc79062ed feat: streamline AI Assistant UI components and improve styles 2026-04-28 19:41:34 +05:30
Yunus M
4b1bb1aef7 feat: implement conversation archiving and restoration functionality in AI Assistant 2026-04-28 11:16:58 +05:30
Yunus M
a0f3b27d15 feat: enhance AI Assistant button with pending user input badge and styles 2026-04-28 00:39:59 +05:30
Yunus M
b3413230c9 feat: add character limit warning to ChatInput component 2026-04-28 00:02:17 +05:30
Yunus M
38f742a470 feat: update openapi.yml and improve ui 2026-04-27 23:52:44 +05:30
Yunus M
1c1fba9ea1 Merge branch 'main' into feat/ai-assistant 2026-04-27 17:41:07 +05:30
Yunus M
2dadb2b39b feat: integrate AI assistant feature with conditional routing and environment configuration 2026-04-22 12:57:14 +05:30
Yunus M
a595feb980 refactor: improve code readability and consistency 2026-04-22 10:36:11 +05:30
Yunus M
40f6994042 feat: enhance home header layout with new HeaderRightSection component and updated styles 2026-04-22 10:00:28 +05:30
Yunus M
542d984d91 refactor: migrate button components to @signozhq/ui and update styles for consistency 2026-04-22 09:54:32 +05:30
Yunus M
c6a885bc31 Merge branch 'main' into feat/ai-assistant 2026-04-22 09:35:32 +05:30
Yunus M
83a9d8fbfe refactor: update Tooltip imports to use @signozhq/ui across components 2026-04-21 14:43:38 +05:30
Yunus M
17a015d244 Merge branch 'main' into feat/ai-assistant 2026-04-21 14:40:53 +05:30
Yunus M
7f9f383a95 Merge branch 'main' into feat/ai-assistant 2026-04-17 20:21:50 +05:30
Yunus M
17a7227831 feat: introduce thinking step and message block structure in AI Assistant 2026-04-17 20:00:26 +05:30
Yunus M
9c8846ae63 chore: remove unused icons and page 2026-04-07 17:26:31 +05:30
Yunus M
18bb87f778 feat: refactor AI Assistant state management to support per-conversation streaming 2026-04-07 16:00:58 +05:30
Yunus M
71a5e4500c feat: implement thread management and feedback submission in AI Assistant 2026-04-06 14:50:27 +05:30
Yunus M
7305470e62 feat: enhance AI Assistant SSE event handling and markdown rendering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:18:08 +05:30
Yunus M
1e140285ae feat: implement message feedback component and enhance message bubble styling 2026-04-03 14:36:19 +05:30
Yunus M
4f039da2a6 feat: enhance voice input functionality and integrate streaming chat 2026-04-02 01:35:09 +05:30
Yunus M
d4afc49882 feat: add AI Assistant action block and speech recognition capabilities 2026-04-01 19:25:16 +05:30
Yunus M
2bce8c9ea0 chore: format taglines for better readability in AIAssistantIconPreview 2026-03-31 16:18:03 +05:30
Yunus M
cae757041a feat: add AI Assistant with interactive blocks for data visualization 2026-03-31 16:16:32 +05:30
Yunus M
adabd1d8db feat: add AI Assistant with interactive blocks for data visualization 2026-03-30 22:08:27 +05:30
254 changed files with 70460 additions and 8064 deletions

View File

@@ -13,6 +13,8 @@ global:
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
# mcp_url: <unset>
# the url of the SigNoz AI Assistant server. when unset, the AI Assistant is hidden in the frontend.
# ai_assistant_url: <unset>
##################### Version #####################
version:

View File

@@ -96,6 +96,122 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- discord_configs
- required:
- email_configs
- required:
- incidentio_configs
- required:
- pagerduty_configs
- required:
- slack_configs
- required:
- webhook_configs
- required:
- opsgenie_configs
- required:
- wechat_configs
- required:
- pushover_configs
- required:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
- mattermost_configs
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
required:
- name
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -133,6 +249,10 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
@@ -145,8 +265,15 @@ components:
ssoEnabled:
type: boolean
ssoType:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- saml
- email_password
- oidc
type: string
AuthtypesAuthNProviderInfo:
properties:
relayStatePath:
@@ -169,11 +296,15 @@ components:
AuthtypesCallbackAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
url:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
@@ -197,7 +328,7 @@ components:
ssoEnabled:
type: boolean
ssoType:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
updatedAt:
format: date-time
type: string
@@ -323,7 +454,7 @@ components:
AuthtypesPasswordAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableObjects:
properties:
@@ -2363,6 +2494,9 @@ components:
type: object
GlobaltypesConfig:
properties:
ai_assistant_url:
nullable: true
type: string
external_url:
type: string
identN:
@@ -2376,6 +2510,7 @@ components:
- external_url
- ingestion_url
- mcp_url
- ai_assistant_url
type: object
GlobaltypesIdentNConfig:
properties:
@@ -5665,7 +5800,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
responses:
"201":
content:
@@ -7042,6 +7177,63 @@ paths:
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain

View File

@@ -0,0 +1,20 @@
---
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.

39763
frontend/package-lock.json generated Normal file

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.12",
"@signozhq/ui": "0.0.13",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -110,6 +110,7 @@
"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",
@@ -133,6 +134,7 @@
"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",
@@ -232,6 +234,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",

9
frontend/req.md Normal file
View File

@@ -0,0 +1,9 @@
# 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

@@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -40,6 +41,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -99,6 +101,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <>{children}</>;
}
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

@@ -18,6 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
@@ -60,6 +61,7 @@ function App(): JSX.Element {
org,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { hostname, pathname } = window.location;
@@ -212,6 +214,27 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
useEffect(() => {
if (!isLoggedInState) {
return;
}
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, isAIAssistantEnabled]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
@@ -221,7 +244,8 @@ function App(): JSX.Element {
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/')
pathname.startsWith('/public/dashboard/') ||
pathname.startsWith('/ai-assistant/')
) {
window.Pylon?.('hideChatBubble');
} else {

View File

@@ -317,3 +317,10 @@ 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,6 +2,7 @@ import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
@@ -496,6 +497,13 @@ 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 = {

455
frontend/src/api/ai/chat.ts Normal file
View File

@@ -0,0 +1,455 @@
/**
* 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 { AIAssistantInstance, getAIBaseUrl } 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(
`${getAIBaseUrl()}/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

@@ -0,0 +1,58 @@
import axios from 'axios';
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
/** Path-only base for the AI Assistant API. */
export const AI_API_PATH = '/api/v1/assistant';
/**
* AI backend URL — sourced from the global config's `ai_assistant_url` field
* at runtime. `useIsAIAssistantEnabled` keeps this in sync via `setAIBackendUrl`
* whenever the config response changes; consumers (the axios instance and the
* SSE fetch path) read it lazily so they always see the current value.
*/
let aiBackendUrl: string | null = null;
export function setAIBackendUrl(url: string | null): void {
aiBackendUrl = url;
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
}
/**
* Full base URL for the AI Assistant API (host + path). Throws when the
* config hasn't yet provided a URL — should never happen in practice
* because `useIsAIAssistantEnabled` gates every consumer surface.
*/
export function getAIBaseUrl(): string {
if (!aiBackendUrl) {
throw new Error('AI assistant URL is not configured.');
}
return `${aiBackendUrl}${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({});
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

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@ import type {
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -277,6 +279,109 @@ export const useDeleteAuthDomain = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns an auth domain by ID
* @summary Get auth domain by ID
*/
export const getAuthDomain = (
{ id }: GetAuthDomainPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
};
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
signal,
}) => getAuthDomain({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAuthDomainQueryResult = NonNullable<
Awaited<ReturnType<typeof getAuthDomain>>
>;
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get auth domain by ID
*/
export function useGetAuthDomain<
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get auth domain by ID
*/
export const invalidateGetAuthDomain = async (
queryClient: QueryClient,
{ id }: GetAuthDomainPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAuthDomainQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates an auth domain
* @summary Update auth domain

View File

@@ -18,6 +18,7 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
@@ -122,14 +123,14 @@ export const invalidateListChannels = async (
* @summary Create notification channel
*/
export const createChannel = (
configReceiverDTO: BodyType<ConfigReceiverDTO>,
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateChannel201>({
url: `/api/v1/channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: configReceiverDTO,
data: alertmanagertypesPostableChannelDTO,
signal,
});
};
@@ -141,13 +142,13 @@ export const getCreateChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
> => {
const mutationKey = ['createChannel'];
@@ -161,7 +162,7 @@ export const getCreateChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createChannel>>,
{ data: BodyType<ConfigReceiverDTO> }
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -174,7 +175,8 @@ export const getCreateChannelMutationOptions = <
export type CreateChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createChannel>>
>;
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
export type CreateChannelMutationBody =
BodyType<AlertmanagertypesPostableChannelDTO>;
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -187,13 +189,13 @@ export const useCreateChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
> => {
const mutationOptions = getCreateChannelMutationOptions(options);

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,46 @@ import {
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { ENVIRONMENT } from 'constants/env';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// 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> => {
@@ -26,5 +58,59 @@ 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

@@ -4,6 +4,49 @@
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,8 +1,17 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Popover } from 'antd';
import { Dot } from '@signozhq/icons';
import { Button, Tooltip } from '@signozhq/ui';
import { 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';
@@ -29,6 +38,7 @@ function HeaderRightSection({
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
@@ -67,9 +77,44 @@ 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"
@@ -83,12 +128,13 @@ function HeaderRightSection({
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
variant="ghost"
size="icon"
className="share-feedback-btn"
aria-label="Feedback"
prefix={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
>
Feedback
</Button>
/>
</Popover>
)}
@@ -105,8 +151,10 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
variant="ghost"
size="icon"
aria-label="Announcements"
prefix={<Inbox size={14} />}
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
@@ -129,12 +177,12 @@ function HeaderRightSection({
onOpenChange={handleOpenShareURLModalChange}
>
<Button
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
variant="ghost"
size="icon"
aria-label="Share"
prefix={<Globe size={14} />}
onClick={handleOpenShareURLModal}
>
Share
</Button>
/>
</Popover>
)}
</div>

View File

@@ -46,6 +46,10 @@ 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

@@ -12,6 +12,7 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('docker', docker);
@@ -31,4 +32,4 @@ SyntaxHighlighter.registerLanguage('yaml', yaml);
SyntaxHighlighter.registerLanguage('yml', yaml);
export default SyntaxHighlighter;
export { a11yDark };
export { a11yDark, oneLight };

View File

@@ -47,10 +47,16 @@ function TanStackCustomTableRow<TData>({
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const enableAlternatingRowColors =
context?.enableAlternatingRowColors ?? false;
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
enableAlternatingRowColors &&
(item.row.index % 2 === 0
? tableStyles.tableRowEven
: tableStyles.tableRowOdd),
extraClass,
);
@@ -105,6 +111,12 @@ function areTableRowPropsEqual<TData>(
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (
prev.context?.enableAlternatingRowColors !==
next.context?.enableAlternatingRowColors
) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;

View File

@@ -2,7 +2,10 @@
position: sticky;
top: 0;
z-index: 2;
padding: 0.3rem;
padding: var(--tanstack-cell-padding-top, 0.3rem)
var(--tanstack-cell-padding-right, 0.3rem)
var(--tanstack-cell-padding-bottom, 0.3rem)
var(--tanstack-cell-padding-left, 0.3rem);
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -19,7 +22,17 @@
}
border: none !important;
background-color: var(--l2-background) !important;
background-color: var(
--tanstack-table-header-cell-bg,
var(--l2-background)
) !important;
&:first-child {
background-color: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
}
}
.tanstackHeaderContent {
@@ -61,7 +74,7 @@
width: 12px;
height: 12px;
cursor: grab;
color: var(--l2-foreground);
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
opacity: 1;
touch-action: none;
}
@@ -74,7 +87,7 @@
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
margin-left: auto;
}
@@ -82,8 +95,9 @@
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--l2-background);
border: 1px solid var(--l2-border);
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
border: 1px solid
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: none;
}
@@ -137,7 +151,7 @@
}
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--bg-robin-300);
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
}
.tanstackResizeHandleLine {
@@ -147,7 +161,7 @@
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-background);
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
opacity: 1;
pointer-events: none;
transition:
@@ -155,13 +169,34 @@
width 120ms ease;
}
.tanstackHeaderCell:first-child .tanstackResizeHandleLine {
background: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
);
}
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--l2-border);
background: var(--tanstack-table-resize-handle-hover-bg, var(--l2-border));
}
.tanstackHeaderCell:first-child
.cursorColResize:hover
.tanstackResizeHandleLine {
background: color-mix(
in srgb,
var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
)
60%,
black
);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--bg-robin-500);
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
transition: none;
}
@@ -213,7 +248,12 @@
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l2-foreground);
color: var(--l3-foreground);
&[data-sort-direction='asc'],
&[data-sort-direction='desc'] {
color: var(--primary);
}
}
.isSortable {

View File

@@ -9,7 +9,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
@@ -177,12 +177,17 @@ function TanStackHeaderRow<TData>({
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className={headerStyles.tanstackSortIndicator}>
<span
className={headerStyles.tanstackSortIndicator}
data-sort-direction={currentSortDirection || 'none'}
>
{currentSortDirection === 'asc' ? (
<ChevronUp size={SORT_ICON_SIZE} />
<ArrowUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
<ArrowDown size={SORT_ICON_SIZE} />
) : (
<ArrowUpDown size={SORT_ICON_SIZE} />
)}
</span>
</button>
) : (

View File

@@ -1,4 +1,22 @@
.tanStackTable {
--tanstack-cell-padding: var(--tanstack-cell-padding-override, 0.3rem);
--tanstack-cell-padding-left: var(
--tanstack-cell-padding-left-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-right: var(
--tanstack-cell-padding-right-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-top: var(
--tanstack-cell-padding-top-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-bottom: var(
--tanstack-cell-padding-bottom-override,
var(--tanstack-cell-padding)
);
width: 100%;
border-collapse: separate;
border-spacing: 0;
@@ -26,7 +44,7 @@
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
max-width: 100%;
word-break: break-all;
}
@@ -42,13 +60,35 @@
}
.tableCell {
padding: 0.3rem;
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: var(--tanstack-table-row-height, auto);
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
background-color: var(--tanstack-table-cell-bg, transparent);
&:first-child {
padding: var(
--tanstack-cell-padding-top-first-column,
var(--tanstack-cell-padding-top)
)
var(
--tanstack-cell-padding-right-first-column,
var(--tanstack-cell-padding-right)
)
var(
--tanstack-cell-padding-bottom-first-column,
var(--tanstack-cell-padding-bottom)
)
var(
--tanstack-cell-padding-left-first-column,
var(--tanstack-cell-padding-left)
);
}
}
.tableRow {
@@ -58,19 +98,69 @@
&:hover {
.tableCell {
background-color: var(--row-hover-bg) !important;
background-color: var(
--tanstack-table-row-hover-bg,
var(--row-hover-bg)
) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(--row-active-bg) !important;
background-color: var(
--tanstack-table-row-active-bg,
var(--row-active-bg)
) !important;
}
}
&.tableRowOdd {
.tableCell {
background-color: var(--tanstack-table-row-odd-bg, transparent);
}
}
&.tableRowEven {
.tableCell {
background-color: var(--tanstack-table-row-even-bg, transparent);
}
}
.tableCell:first-child {
background-color: var(
--tanstack-first-column-bg,
var(--tanstack-table-cell-bg, transparent)
);
color: var(
--tanstack-first-column-color,
var(--tanstack-table-cell-color, var(--l2-foreground))
);
}
&.tableRowOdd .tableCell:first-child {
background-color: var(
--tanstack-first-column-odd-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-odd-bg, transparent)
)
);
}
&.tableRowEven .tableCell:first-child {
background-color: var(
--tanstack-first-column-even-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-even-bg, transparent)
)
);
}
}
.tableHeaderCell {
padding: 0.3rem;
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: 36px;
text-align: left;
font-size: 14px;
@@ -78,20 +168,40 @@
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
background: var(--tanstack-table-header-cell-bg, var(--l1-background));
border-bottom: 1px solid var(--tanstack-table-header-border-color, transparent);
}
.tableRowExpansion {
display: table-row;
.tableCellExpansion {
background-color: var(--tanstack-table-header-cell-bg, var(--l1-background));
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
}
& > td {
padding: 0;
}
& thead th:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-3) - 1px)
);
}
& tbody td:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-20) + var(--spacing-4))
);
}
:global(thead) {
position: unset !important;
}
}
.tableCellExpansion {

View File

@@ -90,6 +90,7 @@ function TanStackTableInner<TData>(
skeletonRowCount = 10,
enableQueryParams,
pagination,
paginationClassname,
onEndReached,
getRowKey,
getItemKey,
@@ -112,9 +113,17 @@ function TanStackTableInner<TData>(
testId,
prefixPaginationContent,
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
throw new Error(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
}
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
@@ -221,6 +230,15 @@ function TanStackTableInner<TData>(
[getRowCanExpand],
);
const isExpandEnabled = Boolean(renderExpandedRow);
useEffect(() => {
const hasExpanded =
typeof expanded === 'boolean' ? expanded : Object.keys(expanded).length > 0;
if (!isExpandEnabled && hasExpanded) {
setExpanded({});
}
}, [isExpandEnabled, expanded, setExpanded]);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
@@ -229,7 +247,7 @@ function TanStackTableInner<TData>(
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
enableExpanding: isExpandEnabled,
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
@@ -333,6 +351,7 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
}),
[
getRowStyle,
@@ -350,6 +369,7 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
],
);
@@ -500,7 +520,10 @@ function TanStackTableInner<TData>(
);
return (
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<div
className={cx(viewStyles.tanstackTableViewWrapper, className)}
data-has-group-by={(groupBy?.length || 0) > 0}
>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
@@ -508,23 +531,53 @@ function TanStackTableInner<TData>(
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
{disableVirtualScroll ? (
<div
className={virtuosoClassName}
{...restTableScrollerProps}
data-testid={testId}
>
<table className={tableStyles.tanStackTable} style={virtuosoTableStyle}>
<VirtuosoTableColGroup columns={effectiveColumns} table={table} />
<thead>{tableHeader()}</thead>
<tbody>
{(isLoading && data.length === 0
? flatItems.slice(0, skeletonRowCount)
: flatItems
).map((item, index) => (
<TanStackCustomTableRow
key={
item.kind === 'expansion' ? `${item.row.id}-expansion` : item.row.id
}
item={item}
context={virtuosoContext}
data-index={index}
data-item-index={index}
data-known-size={0}
/>
))}
</tbody>
</table>
</div>
) : (
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
)}
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
@@ -534,7 +587,7 @@ function TanStackTableInner<TData>(
</div>
)}
{showPagination && pagination && (
<div className={viewStyles.paginationContainer}>
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
{prefixPaginationContent}
<Pagination
current={page}

View File

@@ -49,7 +49,8 @@
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
transparent;
&::-webkit-scrollbar {
width: 4px;
@@ -65,12 +66,12 @@
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
}
&.cellTypographySmall {
@@ -135,18 +136,25 @@
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: var(--tanstack-table-loading-overlay-bg, var(--l1-background));
box-shadow: var(
--tanstack-table-loading-overlay-shadow,
0 2px 8px rgba(0, 0, 0, 0.15)
);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
background: var(
--tanstack-table-scrollbar-hover-color,
var(--bg-vanilla-100)
);
}
}

View File

@@ -389,6 +389,101 @@ describe('TanStackTableView Integration', () => {
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders without errors when expanded state exists but expansion is disabled', async () => {
// This tests that the table handles the case where URL has expanded state
// but renderExpandedRow is undefined (expansion disabled).
// The table's useEffect should reset expanded state automatically.
renderTanStackTable({
props: {
enableQueryParams: true,
// renderExpandedRow is undefined - expansion disabled
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Table should render without any expanded rows
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
});
it('renders expanded rows with unique keys in non-virtualized mode', async () => {
// This tests that row and expansion items have unique keys to avoid
// React's "duplicate key" warning when disableVirtualScroll is true
renderTanStackTable({
props: {
disableVirtualScroll: true,
enableQueryParams: true,
renderExpandedRow: (row) => (
<div data-testid={`expanded-${row.id}`}>Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
getRowKey: (row) => row.id,
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Both the row and its expansion content should be rendered
expect(screen.getByTestId('expanded-1')).toBeInTheDocument();
expect(screen.getByText('Expanded: Item 1')).toBeInTheDocument();
// Verify all 3 data rows plus 1 expansion row = 4 tr elements in tbody
const tbody = screen.getByRole('table').querySelector('tbody');
expect(tbody?.querySelectorAll('tr')).toHaveLength(4);
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
onEndReached: jest.fn(),
},
});
}).toThrow(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
});
it('renders all rows without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
// Verify table structure exists
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders column headers without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
});
describe('infinite scroll', () => {

View File

@@ -138,7 +138,10 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
const { result } = renderHook(() => useTableParams(true), { wrapper });
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
wrapper,
});
expect(result.current.page).toBe(3);
});
@@ -249,3 +252,294 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
expect(result.current.orderBy).toBeNull();
});
});
describe('useTableParams (selective URL mode — partial config object)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('syncs only page to URL when only page is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ page: 'myPage' }), {
wrapper,
});
// Update page - should sync to URL
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myPage'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
// Update limit - should stay local (not in URL)
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('limit') !== null,
);
expect(limitInUrl).toBe(false);
// Update orderBy - should stay local (not in URL)
act(() => {
result.current.setOrderBy({ columnName: 'test', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'test',
order: 'asc',
});
const orderByInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('order_by') !== null,
);
expect(orderByInUrl).toBe(false);
});
it('syncs only orderBy to URL when only orderBy is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ orderBy: 'mySort' }), {
wrapper,
});
// Update orderBy - should sync to URL
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('mySort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
// Update page - should stay local
act(() => {
result.current.setPage(3);
jest.runAllTimers();
});
expect(result.current.page).toBe(3);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only limit to URL when only limit is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ limit: 'myLimit' }), {
wrapper,
});
// Update limit - should sync to URL
act(() => {
result.current.setLimit(25);
jest.runAllTimers();
});
expect(result.current.limit).toBe(25);
const lastLimit = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myLimit'))
.filter(Boolean)
.pop();
expect(lastLimit).toBe('25');
// Update page - should stay local
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only expanded to URL when only expanded is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ expanded: 'myExpanded' }),
{ wrapper },
);
// Update expanded - should sync to URL
act(() => {
result.current.setExpanded({ 'row-1': true, 'row-2': true });
jest.runAllTimers();
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
const lastExpanded = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myExpanded'))
.filter(Boolean)
.pop();
expect(lastExpanded).toBeDefined();
expect(JSON.parse(lastExpanded!)).toEqual(
expect.arrayContaining(['row-1', 'row-2']),
);
// Update page - should stay local
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs page and orderBy to URL but keeps limit and expanded local', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ page: 'p', orderBy: 'sort' }),
{ wrapper },
);
// Update limit and expanded first (should stay local)
act(() => {
result.current.setLimit(75);
result.current.setExpanded({ 'row-5': true });
jest.runAllTimers();
});
expect(result.current.limit).toBe(75);
expect(result.current.expanded).toStrictEqual({ 'row-5': true });
// Update page (should sync to URL)
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('p'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
// Update orderBy (should sync to URL, and resets page to default)
act(() => {
result.current.setOrderBy({ columnName: 'name', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'name',
order: 'asc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('sort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
// limit should NOT be in URL
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('limit') !== null ||
call[0].searchParams.get('myLimit') !== null,
);
expect(limitInUrl).toBe(false);
// expanded should NOT be in URL
const expandedInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('expanded') !== null ||
call[0].searchParams.get('myExpanded') !== null,
);
expect(expandedInUrl).toBe(false);
});
it('reads initial values from URL for configured params only', () => {
const wrapper = createNuqsWrapper({
customPage: '7',
limit: '999', // This should be ignored since limit is not configured
});
const { result } = renderHook(
// Pass page default matching URL to prevent reset on mount
() => useTableParams({ page: 'customPage' }, { page: 7 }),
{ wrapper },
);
// Page should come from URL
expect(result.current.page).toBe(7);
// Limit should be default (not from URL since it's not configured)
expect(result.current.limit).toBe(50);
});
it('supports updater function for expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams({ expanded: 'exp' }), {
wrapper,
});
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
// Use updater function to add another row
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-2': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
});
it('supports updater function for local expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-a': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-a': true });
// Use updater function
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-b': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-a': true,
'row-b': true,
});
});
});

View File

@@ -171,6 +171,27 @@ export * from './useTableParams';
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*
* @example Disable virtual scroll — useful for nested tables inside expanded rows.
* Virtual scroll requires a fixed height container, which is problematic for nested tables
* that need dynamic height. Use `disableVirtualScroll` when rendering tables inside
* `renderExpandedRow` to allow the nested table to grow based on content.
* Note: Cannot be combined with `onEndReached` (infinite scroll requires virtualization).
* ```tsx
* // Parent table with expandable rows
* <TanStackTable
* data={parentData}
* columns={parentColumns}
* renderExpandedRow={(row) => (
* // Nested table without virtualization — height adapts to content
* <TanStackTable
* data={row.children}
* columns={childColumns}
* disableVirtualScroll
* />
* )}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -107,6 +107,8 @@ export type TableRowContext<TData> = {
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
};
export type PaginationProps = {
@@ -116,10 +118,10 @@ export type PaginationProps = {
};
export type TanstackTableQueryParamsConfig = {
page: string;
limit: string;
orderBy: string;
expanded: string;
page?: string;
limit?: string;
orderBy?: string;
expanded?: string;
};
export type TanStackTableProps<TData> = {
@@ -137,6 +139,7 @@ export type TanStackTableProps<TData> = {
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */
@@ -176,6 +179,10 @@ export type TanStackTableProps<TData> = {
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -8,6 +8,12 @@ import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const URL_KEYS_DEFAULT = {
page: 'page',
limit: 'limit',
orderBy: 'order_by',
expanded: 'expanded',
} as const;
type Defaults = {
page?: number;
@@ -49,30 +55,49 @@ export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
// Determine which params should sync to URL vs use local state
const isObjectConfig = typeof enableQueryParams === 'object';
const useUrlForPage =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.page !== undefined);
const useUrlForLimit =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.limit !== undefined);
const useUrlForOrderBy =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.orderBy !== undefined);
const useUrlForExpanded =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.expanded !== undefined);
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
: isObjectConfig
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
: URL_KEYS_DEFAULT.page;
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
: isObjectConfig
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
: URL_KEYS_DEFAULT.limit;
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
: isObjectConfig
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
: URL_KEYS_DEFAULT.orderBy;
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
: isObjectConfig
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
@@ -149,45 +174,29 @@ export function useTableParams(
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (isEnabledQueryParams) {
if (useUrlForPage) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
isEnabledQueryParams,
useUrlForPage,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
expanded: urlExpanded,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
setExpanded: setUrlExpanded,
};
}
return {
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
page: useUrlForPage ? urlPage : localPage,
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
};
}

View File

@@ -35,7 +35,10 @@ export const getColumnWidthStyle = <TData>(
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return { width: '100%' };
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
};
}
const { width } = column;
@@ -59,10 +62,19 @@ export const getColumnWidthStyle = <TData>(
};
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
// Skip accessor for skeleton rows to avoid errors with missing properties
if (isSkeletonRow(row)) {
return undefined;
}
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}

View File

@@ -87,6 +87,8 @@ 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

@@ -0,0 +1,101 @@
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

@@ -0,0 +1,2 @@
export * from './AIAssistantDrawer';
export { default } from './AIAssistantDrawer';

View File

@@ -0,0 +1,100 @@
$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;
display: flex;
align-items: center;
gap: 4px;
}
.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

@@ -0,0 +1,213 @@
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}>
<span></span>
<span>J</span>
</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

@@ -0,0 +1,2 @@
export * from './AIAssistantModal';
export { default } from './AIAssistantModal';

View File

@@ -0,0 +1,62 @@
$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

@@ -0,0 +1,163 @@
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

@@ -0,0 +1,2 @@
export * from './AIAssistantPanel';
export { default } from './AIAssistantPanel';

View File

@@ -0,0 +1,32 @@
.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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,2 @@
export * from './AIAssistantTrigger';
export { default } from './AIAssistantTrigger';

View File

@@ -0,0 +1,53 @@
.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

@@ -0,0 +1,149 @@
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]);
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}
/>
{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}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ConversationView';
export { default } from './ConversationView';

View File

@@ -0,0 +1,649 @@
# 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

@@ -0,0 +1,911 @@
# 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

@@ -0,0 +1,8 @@
// 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

@@ -0,0 +1,32 @@
@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

@@ -0,0 +1,80 @@
/**
* 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

@@ -0,0 +1,2 @@
export * from './AIAssistantIcon';
export { default } from './AIAssistantIcon';

View File

@@ -0,0 +1,118 @@
$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(--primary);
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

@@ -0,0 +1,560 @@
import { useEffect, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import cx from 'classnames';
import { v4 as uuidv4 } from 'uuid';
import { Button, Tooltip } from '@signozhq/ui';
import type { MessageActionDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
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;
}
}
/**
* The agent emits `action.query` as the SigNoz REST query-range request body:
*
* - V5 (current backend): `{ ..., compositeQuery: { queries: [{ type, spec }] } }`
* — each `spec` already carries `filter.expression` directly.
* - V3 (legacy): `{ ..., compositeQuery: { builderQueries: { A: {...} } } }`
*
* The URL's `compositeQuery` param expects the in-app shape
* (`{ queryType, builder: { queryData: [...], queryFormulas, queryTraceOperator }, ... }`).
* `mapQueryDataFromApi` already handles both API shapes for query-range
* responses, so we delegate to it instead of maintaining a parallel translator.
*
* Defensive: if the agent ever sends the URL shape directly (top-level
* `builder.queryData`), we pass it through unchanged.
*/
function toUrlCompositeQuery(
actionQuery: Record<string, unknown>,
): Record<string, unknown> | null {
// Already in URL shape — use as-is (with envelope defaults filled in).
if (
actionQuery.builder &&
typeof actionQuery.builder === 'object' &&
Array.isArray((actionQuery.builder as Record<string, unknown>).queryData)
) {
return {
queryType: actionQuery.queryType ?? 'builder',
promql: actionQuery.promql ?? [],
clickhouse_sql: actionQuery.clickhouse_sql ?? [],
id: uuidv4(),
unit: actionQuery.unit ?? '',
...actionQuery,
};
}
// API shape: extract the inner compositeQuery and let the shared mapper
// normalise V3/V5 spec → IBuilderQuery for us.
const composite = (actionQuery.compositeQuery ?? actionQuery) as
| Record<string, unknown>
| undefined;
if (!composite) {
return null;
}
try {
const mapped = mapQueryDataFromApi(
composite as unknown as ICompositeMetricQuery,
);
// `mapQueryDataFromApi` falls back to `initialQueryState.builder` when
// neither `queries` nor `builderQueries` is present — detect that and
// signal "unrecognised payload" instead of silently navigating to an
// empty query.
if (mapped.builder.queryData.length === 0) {
return null;
}
return mapped as unknown as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Tracks apply_filter action keys that have already been auto-applied so we
* don't re-fire on re-renders / re-mounts. Module-level (intentionally) — it's
* not state we'd ever want to reset on a component unmount; the action's
* filters are already on the URL after the first auto-apply.
*/
const autoAppliedFilterKeys = new Set<string>();
/**
* True when the user is currently on the explorer that an apply_filter
* action targets — i.e. when auto-applying makes sense (the page is mounted
* and ready to react to a URL change without a route transition).
*/
function signalMatchesPathname(
signal: ApplyFilterSignalDTO,
pathname: string,
): boolean {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return Boolean(
matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.traces:
return Boolean(
matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.metrics:
return Boolean(
matchPath(pathname, {
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: false,
}),
);
default:
return false;
}
}
/**
* Stable per-action key used both to dedupe auto-applies and as the React key
* for the chip. Mirrors the same construction we do in the render loop below.
*/
function actionKey(action: MessageActionDTO, index: number): string {
return action.actionMetadataId
? `${action.kind}:${action.actionMetadataId}`
: `${action.kind}:${action.label}:${index}`;
}
/** Maps a signal to its target explorer route. */
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return ROUTES.LOGS_EXPLORER;
case ApplyFilterSignalDTO.traces:
return ROUTES.TRACES_EXPLORER;
case ApplyFilterSignalDTO.metrics:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}
interface ApplyFilterDeps {
history: ReturnType<typeof useHistory>;
pathname: string;
redirectWithQueryBuilderData: ReturnType<
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
}
/**
* The V5 query-builder UI binds the WHERE clause CodeMirror editor to
* `builder.queryData[i].filter.expression`. The agent normally only sends
* `filters.items`, so we derive the expression per query before pushing
* state. Same recipe as `pages/<X>/aiActions.ts` — keeps the immediate
* UI update consistent with what the URL parser would produce on reload.
*/
function withDerivedFilterExpressions(query: Query): Query {
const queryData = query.builder.queryData.map((q): IBuilderQuery => {
const items = q.filters?.items ?? [];
if (items.length === 0) {
return q;
}
const filters: TagFilter = { items, op: q.filters?.op || 'AND' };
return {
...q,
filters,
filter: convertFiltersToExpression(filters),
};
});
return { ...query, builder: { ...query.builder, queryData } };
}
/**
* Single entry point for an apply_filter action — used by both the auto-apply
* effect (fired once when the user is already on the matching explorer) and
* the manual chip-click handler.
*
* - On-page: push each builder query into the QueryBuilder provider via
* `handleSetQueryData` so the WHERE clause re-renders immediately, then
* `redirectWithQueryBuilderData` to persist it on the URL. Mirrors the
* page-action recipe — calling redirect alone is not sufficient because
* the URL→state effect runs after the next render and the editor binds
* to `filter.expression`, not `filters.items`.
* - Off-page: use `history.push` so the landing explorer initializes from
* the new URL on mount.
*/
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
// eslint-disable-next-line no-console
console.log('[apply_filter] enter', {
signal: action.signal,
query: action.query,
pathname: deps.pathname,
});
if (!action.signal || !action.query) {
// eslint-disable-next-line no-console
console.warn('[apply_filter] bail: missing signal or query', action);
return;
}
const urlQuery = toUrlCompositeQuery(action.query as Record<string, unknown>);
if (!urlQuery) {
// eslint-disable-next-line no-console
console.warn(
'[apply_filter] bail: toUrlCompositeQuery returned null — agent payload shape unrecognized',
action.query,
);
return;
}
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
if (!base) {
// eslint-disable-next-line no-console
console.warn('[apply_filter] bail: no route for signal', action.signal);
return;
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** 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 { pathname } = useLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
// 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>>({});
// Auto-apply any apply_filter action whose signal matches the page the
// user is currently on (logs/traces/metrics explorer). Same code path as
// the manual click below — just fired automatically once. The chip stays
// clickable as a fallback for the off-page case. Dedupes via a module-
// level set so re-renders / re-mounts don't re-fire.
useEffect(() => {
actions.forEach((action, i) => {
if (action.kind !== MessageActionKindDTO.apply_filter) {
return;
}
if (!action.signal || !action.query) {
return;
}
if (!signalMatchesPathname(action.signal, pathname)) {
return;
}
const key = actionKey(action, i);
if (autoAppliedFilterKeys.has(key)) {
return;
}
autoAppliedFilterKeys.add(key);
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
});
});
}, [
actions,
pathname,
history,
redirectWithQueryBuilderData,
handleSetQueryData,
]);
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 => {
// eslint-disable-next-line no-console
console.log('[ActionsSection] chip click', { kind: action.kind, action });
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;
}
case MessageActionKindDTO.apply_filter: {
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
});
break;
}
default:
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 (shared with the auto-apply dedupe set).
// `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 = actionKey(action, 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

@@ -0,0 +1,2 @@
export * from './ActionsSection';
export { default } from './ActionsSection';

View File

@@ -0,0 +1,284 @@
@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

@@ -0,0 +1,472 @@
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,
ApprovalEventDTODiff,
} 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: ApprovalEventDTODiff;
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

@@ -0,0 +1,2 @@
export * from './ApprovalCard';
export { default } from './ApprovalCard';

View File

@@ -0,0 +1,456 @@
@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

@@ -0,0 +1,941 @@
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;
}
/** 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,
}: 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();
// 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

@@ -0,0 +1,2 @@
export * from './ChatInput';
export { default } from './ChatInput';

View File

@@ -0,0 +1,135 @@
$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

@@ -0,0 +1,352 @@
import { useState } from 'react';
import cx from 'classnames';
import {
Button,
Checkbox,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui';
import { ClarificationFieldTypeDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
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, initialAnswerFor(f)]),
);
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 — covers every variant of ClarificationFieldTypeDTO:
// text, number, select, multi_select, boolean.
// ---------------------------------------------------------------------------
/**
* Per-type seed value. The DTO's `default` is `string | string[] | null`,
* which doesn't fit boolean fields cleanly — we coerce 'true'/'false' strings
* for them, fall back to `[]` for multi_select, and the raw string otherwise.
*/
function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
const raw = f.default;
if (f.type === ClarificationFieldTypeDTO.boolean) {
// `default` is typed string | string[] | null; backend sends
// 'true'/'false' as strings for boolean fields.
return raw === 'true';
}
if (f.type === ClarificationFieldTypeDTO.multi_select) {
return Array.isArray(raw) ? raw : [];
}
return raw ?? '';
}
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 /
// multi_select 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 === ClarificationFieldTypeDTO.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>
);
}
// Boolean — single yes/no checkbox. The label sits inside the checkbox
// so the click target covers both, matching how multi_select rows render.
if (type === ClarificationFieldTypeDTO.boolean) {
const checked = value === true;
return (
<div className={styles.field}>
<Checkbox
className={styles.checkboxLabel}
value={checked}
onChange={(): void => onChange(!checked)}
>
{label}
{required && <span className={styles.required}>*</span>}
</Checkbox>
</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 === ClarificationFieldTypeDTO.multi_select &&
(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

@@ -0,0 +1,2 @@
export * from './ClarificationForm';
export { default } from './ClarificationForm';

View File

@@ -0,0 +1,147 @@
$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;
}
}
// Compact menu — narrower than the design-system default so the
// content (Rename / Copy link / Archive) doesn't dwarf the row.
.menu {
min-width: 160px !important;
width: 160px !important;
}
// Shared sizing for every dropdown item so the menu reads compact —
// matches the row's own 12px label scale.
.menuItem {
font-size: 12px !important;
cursor: pointer !important;
}
// Amber treatment for the destructive-but-recoverable Archive action —
// less alarming than red since the conversation can be restored later.
// Targets both the label text and the leading icon (icons inherit color
// via `currentColor`).
.archiveItem {
color: var(--bg-amber-400, #fbbf24) !important;
svg {
color: inherit !important;
}
}
.restoreItem {
color: var(--primary) !important;
svg {
color: inherit !important;
}
}

View File

@@ -0,0 +1,219 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
import ROUTES from 'constants/routes';
import { getAbsoluteUrl } from 'utils/basePath';
import { Button, Dropdown, Input, toast } from '@signozhq/ui';
import {
Archive,
ArchiveRestore,
EllipsisVertical,
Link,
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;
onArchive: (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,
onArchive,
onRestore,
}: ConversationItemProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [, copyToClipboard] = useCopyToClipboard();
const isArchived = Boolean(conversation.archived);
const displayTitle = conversation.title ?? 'New conversation';
const ts = conversation.updatedAt ?? conversation.createdAt;
const handleCopyLink = useCallback((): void => {
// Prefer the server-side `threadId` so the link resolves for anyone
// with access to this conversation. Fall back to the local id for
// drafts that haven't synced yet — useful for the current session
// even if the URL won't reload elsewhere.
const id = conversation.threadId ?? conversation.id;
const path = ROUTES.AI_ASSISTANT.replace(':conversationId', id);
copyToClipboard(getAbsoluteUrl(path));
toast.success('Conversation link copied to clipboard');
}, [conversation.threadId, conversation.id, copyToClipboard]);
const startEditing = useCallback((): void => {
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 itemClass = cx(styles.item, {
[styles.active]: isActive,
[styles.archived]: isArchived,
});
// Dropdown items mirror the previous inline buttons but live in a single
// trigger so the row stays compact. Archive/Restore swap based on the
// archived state — same handler wiring as before.
const baseItems = [
{
key: 'rename',
label: 'Rename',
icon: <Pencil size={12} />,
className: styles.menuItem,
onClick: (): void => startEditing(),
},
{
key: 'copy-link',
label: 'Copy link',
icon: <Link size={12} />,
className: styles.menuItem,
onClick: handleCopyLink,
},
{ type: 'divider' as const, key: 'divider' },
];
const menuItems = isArchived
? [
...baseItems,
{
key: 'restore',
label: 'Restore',
icon: <ArchiveRestore size={12} />,
className: cx(styles.menuItem, styles.restoreItem),
onClick: (): void => onRestore(conversation.id),
},
]
: [
...baseItems,
{
key: 'archive',
label: 'Archive',
icon: <Archive size={12} />,
className: cx(styles.menuItem, styles.archiveItem),
onClick: (): void => onArchive(conversation.id),
},
];
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}
// Stop the row's onSelect from firing when the user opens the
// menu or clicks an item — the menu lives in a portal so its
// own clicks don't bubble, but the trigger button does.
onClick={(e): void => e.stopPropagation()}
>
<Dropdown
menu={{ items: menuItems }}
align="end"
sideOffset={4}
className={styles.menu}
>
<Button
variant="link"
size="icon"
color="none"
className={styles.btn}
aria-label="Conversation actions"
prefix={<EllipsisVertical size={12} />}
/>
</Dropdown>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ConversationItem';
export { default } from './ConversationItem';

View File

@@ -0,0 +1,90 @@
$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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,2 @@
export * from './ConversationSkeleton';
export { default } from './ConversationSkeleton';

View File

@@ -0,0 +1,138 @@
@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;
}
.searchBar {
padding: 0 12px 8px;
flex-shrink: 0;
}
.search {
width: 100%;
font-size: 12px;
}
.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 {
display: flex;
flex-direction: column;
gap: 2px;
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

@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { Input } from '@signozhq/ui';
import { Search } from '@signozhq/icons';
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 archiveConversation = useAIAssistantStore((s) => s.archiveConversation);
const restoreConversation = useAIAssistantStore((s) => s.restoreConversation);
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
const [searchQuery, setSearchQuery] = useState('');
// Fetch threads from backend on mount
useEffect(() => {
void fetchThreads();
}, [fetchThreads]);
// Case-insensitive substring match against the conversation title.
// Untitled conversations match the literal placeholder so users
// searching for "new" can still find them.
const trimmedQuery = searchQuery.trim().toLowerCase();
const matchesQuery = (c: Conversation): boolean => {
if (!trimmedQuery) {
return true;
}
const title = (c.title ?? 'New conversation').toLowerCase();
return title.includes(trimmedQuery);
};
const sortedActive = useMemo(
() =>
Object.values(conversations)
.filter((c) => !c.archived && matchesQuery(c))
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[conversations, trimmedQuery],
);
const sortedArchived = useMemo(
() =>
Object.values(conversations)
.filter((c) => Boolean(c.archived) && c.threadId && matchesQuery(c))
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[conversations, trimmedQuery],
);
const groups = useMemo(() => groupByDate(sortedActive), [sortedActive]);
const hasAnySidebarRows = groups.length > 0 || sortedArchived.length > 0;
const isSearching = trimmedQuery.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.searchBar}>
<Input
type="text"
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
placeholder="Search conversations…"
prefix={<Search size={12} />}
className={styles.search}
/>
</div>
<div className={styles.list} aria-busy={isLoadingThreads}>
{isLoadingThreads && (
<span className={styles.srOnly} role="status">
Loading conversations
</span>
)}
{!isLoadingThreads && !hasAnySidebarRows && (
<p className={styles.empty}>
{isSearching ? 'No matching conversations.' : '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}
onArchive={archiveConversation}
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}
onArchive={archiveConversation}
onRestore={restoreConversation}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './HistorySidebar';
export { default } from './HistorySidebar';

View File

@@ -0,0 +1,331 @@
$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;
}
}
.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%;
min-width: 60%;
.user & {
background: var(--primary, var(--bg-robin-500));
color: #fff;
border-bottom-right-radius: $radius;
}
.assistant & {
// Flex column for text blocks, tool steps and cards. No parent
// gap — auxiliary blocks (Thinking / ToolCall / actions) stack
// flush, and the prose `.markdown` block adds its own 24px top
// and bottom margins to mark itself as the message's focal point.
display: flex;
flex-direction: column;
align-items: flex-start;
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%;
}
.markdown {
width: 100%;
word-break: break-word;
// Anchor the prose block apart from any auxiliary rows (Thinking /
// ToolCall / Suggested actions) above and below it. Reset when this
// is the only / first / last child so the bubble doesn't grow taller
// than its content.
margin: 16px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
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;
padding: 4px 0;
height: 20px;
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

@@ -0,0 +1,160 @@
import React from 'react';
import cx from 'classnames';
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;
isLastAssistant?: boolean;
}
export default function MessageBubble({
message,
onRegenerate,
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 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>
</div>
{!isUser && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}
isLastAssistant={isLastAssistant}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './MessageBubble';
export { default } from './MessageBubble';

View File

@@ -0,0 +1,13 @@
// 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

@@ -0,0 +1,55 @@
.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

@@ -0,0 +1,157 @@
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

@@ -0,0 +1,2 @@
export * from './MessageFeedback';
export { default } from './MessageFeedback';

View File

@@ -0,0 +1,9 @@
.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

@@ -0,0 +1,132 @@
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/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

@@ -0,0 +1,2 @@
export * from './StreamingMessage';
export { default } from './StreamingMessage';

View File

@@ -0,0 +1,46 @@
// Minimal expandable row — chevron + label, no icon, no left rail.
// Matches the tool-call row treatment so consecutive thinking + tool
// activity reads as one quiet "what the agent did" log.
.row {
width: 100%;
font-size: 12px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground, #6b7280);
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
}
.label {
font-weight: 400;
}
.body {
margin-top: 4px;
padding: 4px 0 4px 22px;
}
.content {
font-size: 12px;
color: var(--l3-foreground, #6b7280);
font-style: italic;
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}

View File

@@ -0,0 +1,36 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
}
/** Collapsible thinking row — chevron + label, content in the expanded body. */
export default function ThinkingStep({
content,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={styles.row}>
<div className={styles.header} onClick={toggle}>
{expanded ? (
<ChevronDown size={12} className={styles.chevron} />
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>Thinking</span>
</div>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ThinkingStep';
export { default } from './ThinkingStep';

View File

@@ -0,0 +1,102 @@
$radius: 4px;
// Minimal expandable row — chevron + label, no icon, no left rail.
// While the tool is running we swap the chevron for a spinner in the
// same slot so the row alignment doesn't shift when it completes.
.row {
width: 100%;
font-size: 12px;
&.running {
opacity: 0.85;
}
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground, #6b7280);
user-select: none;
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
&.spin {
color: var(--primary, var(--bg-robin-500));
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.label {
font-weight: 400;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
margin-top: 4px;
padding: 4px 0 4px 22px;
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

@@ -0,0 +1,63 @@
import { useState } from 'react';
import cx from 'classnames';
import { ChevronDown, ChevronRight, LoaderCircle } from '@signozhq/icons';
import { StreamingToolCall } from '../../types';
import styles from './ToolCallStep.module.scss';
interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
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);
return (
<div className={cx(styles.row, { [styles.running]: !done })}>
<div className={styles.header} onClick={toggle}>
{!done ? (
<LoaderCircle size={12} className={cx(styles.chevron, styles.spin)} />
) : expanded ? (
<ChevronDown size={12} className={styles.chevron} />
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>{label}</span>
</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

@@ -0,0 +1,2 @@
export * from './ToolCallStep';
export { default } from './ToolCallStep';

View File

@@ -0,0 +1,89 @@
@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

@@ -0,0 +1,195 @@
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;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
}: 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
}
isLastAssistant={isLastAssistant}
/>
);
}
return (
<StreamingMessage
conversationId={conversationId}
events={streamingEvents}
status={streamingStatus}
pendingApproval={pendingApproval}
pendingClarification={pendingClarification}
/>
);
}}
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from './VirtualizedMessages';
export { default } from './VirtualizedMessages';

View File

@@ -0,0 +1,104 @@
.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

@@ -0,0 +1,203 @@
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

@@ -0,0 +1,2 @@
export * from './ActionBlock';
export { default } from './ActionBlock';

View File

@@ -0,0 +1,126 @@
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

@@ -0,0 +1,2 @@
export * from './BarChartBlock';
export { default } from './BarChartBlock';

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