Compare commits

...

28 Commits

Author SHA1 Message Date
Jatinderjit Singh
7f6e4166cc log maintenance window for schedule-recurrence timestamp mismatch 2026-05-13 17:09:36 +05:30
Jatinderjit Singh
409fff9ac3 Revert "send empty start/end dates in frontend for recurring windows"
This reverts commit 0470cc7a84f6e9f91cccd73d7841b884342031d4.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
ebde9a60e8 Revert "handle zero start and end times in schedule"
This reverts commit 58a5aecb82f1aa4f8d5549e391f1f2c5c7574be2.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
d4c840113a Revert "Revert "send empty start/end dates in frontend for recurring windows""
This reverts commit 15a4166d3740877b601f16ba208dd3c291b387f2.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
f9c2c4ee6a Revert "Remove start and end time from recurrence"
This reverts commit ab0df8e22d6099772eec79af11d2453a9d95e157.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
f78a078b22 Revert "fix display timezone"
This reverts commit 9b2a61674e883f2b47f5bd52413e257ef6f861d3.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
7f1b78c314 Revert "remove redundant param shouldKeepLocalTime"
This reverts commit ed942426745b8b534cdc47dc8b885beef0d6c2f1.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
1b74b5ecc6 Revert "handle empty initial start time"
This reverts commit 82e7c72a338b019dea57def1c61795ca749aacc0.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
1b7d7ced08 Revert "fix CI issues"
This reverts commit 772e6486bb03ec836ebdce436e820aa0d1defdda.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
cb224770f5 fix CI issues 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
c2ef73a694 handle empty initial start time 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
2a8fadc6c0 remove redundant param shouldKeepLocalTime 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
8b753136b4 fix display timezone 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
68a3961898 Remove start and end time from recurrence 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
178f51700a Revert "send empty start/end dates in frontend for recurring windows"
This reverts commit 87bc3fae274ccfd9ce98aeae5ac379fadf657df3.
2026-05-13 17:06:49 +05:30
Jatinderjit Singh
bc8ff8abc0 handle zero start and end times in schedule 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
76a055362b send empty start/end dates in frontend for recurring windows 2026-05-13 17:06:49 +05:30
Jatinderjit Singh
8155dd32e5 fix: maintenance ignores recurrence when fixed times also set 2026-05-13 17:06:49 +05:30
SagarRajput-7
d15065b808 feat(authz): enable multi role assignment for members page (#11269)
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(authz): enable multi role assignment for members page

* feat(authz): enable multi role assignemnt for users

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-05-13 11:16:59 +00:00
Nageshbansal
757c4e8ea9 chore: revert the v0.123.0 release (#11286) 2026-05-13 10:45:12 +00:00
Nityananda Gohain
b55c009c31 fix: disable opamp integration for llm (#11284) 2026-05-13 08:38:29 +00:00
Gaurav Tewari
9cb6228da5 fix: replace logs with list icon (#11283)
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-05-13 08:11:45 +00:00
Nikhil Mantri
951f55b062 feat(infra-monitoring): v2 deployments list api (#11140)
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: nodes list v2 full blown

* chore: metadata fix

* chore: updated comment

* chore: namespaces code

* chore: v2 nodes api

* chore: rename

* chore: v2 clusters list api

* chore: namespaces code

* chore: rename

* chore: review clusters PR

* chore: pvcs code added

* chore: updated endpoint and spec

* chore: pvcs todo

* chore: added condition

* chore: added filter

* chore: added code for deployments

* chore: query nit

* chore: added base deployments change

* chore: added base condition

* chore: added pod phase counts

* chore: for pods and nodes, replace none with no_data

* chore: node and pod counts structs added

* chore: namespace record uses PodCountsByPhase

* chore: cluster record uses PodCountsByPhase, NodeCountsByReadiness

* chore: deployment record uses PodCountsByPhase

* chore: metrics existence check

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-05-13 06:59:08 +00:00
primus-bot[bot]
42ef704077 chore(release): bump to v0.123.0 (#11282)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-13 06:58:11 +00:00
Aditya Singh
515220194d Feat/trace details pending 2 (#11255)
* feat: span details init

* feat: span details header

* feat: details field component

* feat: added span percentile

* feat: key attr section added

* feat: added pretty view

* feat: update yarn lock

* feat: minor change

* feat: search in pretty view

* feat: refactor

* feat: style fix

* feat: json viewer with select dropdown added

* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: send selected span when flamegraph is sampled

* feat: only scroll when span is not in view

* feat: auto expand on highlight errors

* feat: move analytics panel

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: move to zustand

* feat: update test cases

* feat: update border color

* feat: add icons

* feat: support filter on parent keys

* feat: add links to non filterable keys

* feat: minor fix

* feat: use pinned attributes accross views

* feat: update tests

* feat: hide v3

---------

Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-13 06:07:25 +00:00
Yunus M
ac5ccbf186 feat: AI Assistant UI (#10992)
* feat: add AI Assistant with interactive blocks for data visualization

* feat: add AI Assistant with interactive blocks for data visualization

* chore: format taglines for better readability in AIAssistantIconPreview

* feat: add AI Assistant action block and speech recognition capabilities

* feat: enhance voice input functionality and integrate streaming chat

* feat: implement message feedback component and enhance message bubble styling

* feat: enhance AI Assistant SSE event handling and markdown rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: implement thread management and feedback submission in AI Assistant

* feat: refactor AI Assistant state management to support per-conversation streaming

* chore: remove unused icons and page

* feat: introduce thinking step and message block structure in AI Assistant

* refactor: update Tooltip imports to use @signozhq/ui across components

* refactor: migrate button components to @signozhq/ui and update styles for consistency

* feat: enhance home header layout with new HeaderRightSection component and updated styles

* refactor: improve code readability and consistency

* feat: integrate AI assistant feature with conditional routing and environment configuration

* feat: update openapi.yml and improve ui

* feat: add character limit warning to ChatInput component

* feat: enhance AI Assistant button with pending user input badge and styles

* feat: implement conversation archiving and restoration functionality in AI Assistant

* feat: streamline AI Assistant UI components and improve styles

* feat: add MessageContext interface and enhance message sending functionality in AI Assistant

* feat: update AI Assistant styles and components to use new icon library and improve layout

* feat: move to css modules

* feat: enhance AI Assistant with new API integration and UI components

* refactor: update AIAssistant components to use Button from Signoz UI and enhance styling

* refactor: simplify action handling and enhance streaming message indicators

* refactor: improve loading indicators in HistorySidebar component

* refactor: enhance ConversationView loading state and improve action key stability

* refactor: implement AIAssistant axios instance and enhance SSE authentication handling

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

* refactor: enhance ChatInput component with improved overflow handling and context fetching logic

* refactor: enhance AIAssistantModal and ConversationItem components with improved key handling and UI

* refactor: streamline Spinner component and update ChatInput styles for improved UI consistency

* feat: implement push-to-talk functionality in ChatInput for improved voice interaction

* feat: add edit and resend functionality to user messages in ConversationView and ChatInput

* feat: implement AI Assistant UI components for enhanced user interaction

* feat: improve tool call instance rendering

* feat: add accessibility attributes and feedback buttons to HeaderRightSection

* chore: update openapi.yaml with improved formatting and structure for API documentation

* feat: enhance AI Assistant components with new enums, improved clarification handling, code block

* refactor: remove edit and resend functionality, streamline message handling

* refactor: remove AI backend URL from environment variables

* refactor: update styles and structure for MessageBubble, ThinkingStep, and ToolCallStep components

* feat: enhance conversation item and history sidebar with search functionality and dropdown actions

* feat: update AI Assistant UI components with new icons

* feat: add displayText property to ToolCallBlock

* feat: implement message regeneration functionality in AI Assistant

* feat: pass comment for negative feedback

* fix(frontend): position chat support around AI assistant

* feat: add SigNoz URL header to AI Assistant requests for multi-tenant support

* feat: add header actions to TraceDetailV2 for sharing and feedback

* chore: remove hardcoded url

* fix: skip custom AI block markers in language validation script

* feat: implement AI API instance with request/response interceptors

* feat: enhance AI Assistant conversation handling with hydration state

* feat: implement SSE backoff strategy and error handling in AI Assistant

* refactor: update color variables in AI Assistant styles for consistency and improved theming

* chore: increase margin for title in Block component for improved spacing

* chore: fmt

* feat: use default domain url for X-SigNoz-URL

* refactor: simplify AIAssistant enabled state management across components

* refactor: replace HistorySidebar with ConversationsList component in AIAssistant

* feat: improve css

* feat: enhance voice input functionality with microphone permission handling

* feat: add rule to enforce subpath imports for @signozhq/ui components

* fix: renameConversation requires reload to render

* refactor: update imports for @signozhq/ui components to use subpath imports

* refactor: adjust imports for @signozhq/ui components to use specific subpath imports

* chore: remove temp files

* chore: remove temp files

* chore: move types to ai assistant folder

* chore: remove unused chart blocks from AI assistant

Removes BarChart, LineChart, PieChart, and Timeseries block components
along with the shared chartSetup and Chart.module.scss. These rendered
hex-color literals outside the design-token system and weren't being
emitted by any current response flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: update header button color to use accent primary

* chore: remove the rule files

* chore: remove react chartjs

* refactor: replace hardcoded radius values with CSS variables

* chore: use css variable

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: makeavish <makeavish786@gmail.com>
2026-05-13 06:03:02 +00:00
Yunus M
49bfb01f4c feat: add lint rule to enforce subpath imports for Signoz UI components (#11275)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add lint rule to enforce subpath imports for Signoz UI components

* chore: add auto fix
2026-05-12 19:41:59 +00:00
Aditya Singh
7f6bdcbb8c Feat/trace details pending (#11170)
* fix: style fix

* fix: update color

* feat: bg color for selected and hover spans

* feat: remove unnecessary props

* feat: minor comment added

* feat: add test cases for flamegraph

* feat: add test utils

* feat: waterfall init

* feat: decouple waterfall left (span tree) and right (timeline bars) panels

Split the waterfall into two independent panels with a shared virtualizer
so deeply nested span names are visible via horizontal scroll in the left
panel. Left panel uses useReactTable + <table> for future column
extensibility; right panel uses plain divs for timeline bars. A draggable
resize handle separates the two panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TimelineV3 ruler to waterfall header with padding fix

Add the TimelineV3 component to the sticky header of the waterfall's
right panel so timeline tick marks are visible. Add horizontal padding
to both the timeline header and span duration bars to prevent label
overflow/clipping at the edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: match span style

* feat: fix hover option overflow

* feat: span hover popover sync

* feat: row based flamegraph

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: move to service worker

* feat: connector line ux

* feat: event dots in trace details

* feat: waterfall resizable

* feat: span details init

* feat: span details header

* feat: details field component

* feat: added span percentile

* feat: key attr section added

* feat: added pretty view

* feat: update yarn lock

* feat: minor change

* feat: search in pretty view

* feat: refactor

* feat: style fix

* feat: json viewer with select dropdown added

* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: minor fix

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-12 15:13:39 +00:00
231 changed files with 21327 additions and 1176 deletions

View File

@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.3
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.3
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.3
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.3
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -2579,6 +2579,76 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesDeploymentRecord:
properties:
availablePods:
type: integer
deploymentCPU:
format: double
type: number
deploymentCPULimit:
format: double
type: number
deploymentCPURequest:
format: double
type: number
deploymentMemory:
format: double
type: number
deploymentMemoryLimit:
format: double
type: number
deploymentMemoryRequest:
format: double
type: number
deploymentName:
type: string
desiredPods:
type: integer
meta:
additionalProperties:
type: string
nullable: true
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- deploymentName
- deploymentCPU
- deploymentCPURequest
- deploymentCPULimit
- deploymentMemory
- deploymentMemoryRequest
- deploymentMemoryLimit
- desiredPods
- availablePods
- podCountsByPhase
- meta
type: object
InframonitoringtypesDeployments:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesHostFilter:
properties:
expression:
@@ -2909,6 +2979,32 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableDeployments:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableHosts:
properties:
end:
@@ -11976,6 +12072,81 @@ paths:
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/deployments:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes Deployments with key aggregated
pod metrics: CPU usage and memory working set summed across pods owned by
the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest,
deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each
row also reports the latest known desiredPods (k8s.deployment.desired) and
availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase
({ pending, running, succeeded, failed, unknown } from each pod''s latest
k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name,
k8s.namespace.name, k8s.cluster.name). The response type is ''list'' for the
default k8s.deployment.name grouping or ''grouped_list'' for custom groupBy
keys; in both modes every row aggregates pods owned by deployments in the
group. Supports filtering via a filter expression, custom groupBy, ordering
by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
/ desired_pods / available_pods, and pagination via offset/limit. Also reports
missing required metrics and whether the requested time range falls before
the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest,
deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit,
desiredPods, availablePods) return -1 as a sentinel when no data is available
for that field.'
operationId: ListDeployments
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableDeployments'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesDeployments'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Deployments for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/hosts:
post:
deprecated: false

View File

@@ -114,7 +114,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, signoz.Modules.LLMPricingRule},
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
})
if err != nil {
return nil, err

View File

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

View File

@@ -291,6 +291,8 @@
// Prevents window.open(path), window.location.origin + path, window.location.href = path
"signoz/no-antd-components": "error",
// Prevents the usage of specific antd components in favor of our lib
"signoz/no-signozhq-ui-barrel": "error",
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
"no-restricted-globals": [
"error",
{
@@ -495,7 +497,8 @@
"overrides": [
{
"files": [
"src/api/generated/**/*.ts"
"src/api/generated/**/*.ts",
"src/api/ai-assistant/**/*.ts"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",

View File

@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
(window as any).IntersectionObserver = IntersectionObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -135,6 +135,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",
@@ -173,18 +174,18 @@
"@commitlint/config-conventional": "20.4.4",
"@faker-js/faker": "9.3.0",
"@jest/globals": "30.2.0",
"@jest/types": "30.2.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/dompurify": "^2.4.0",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/fontfaceobserver": "2.1.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@jest/types": "30.2.0",
"@types/lodash-es": "^4.17.4",
"@types/mini-css-extract-plugin": "^2.5.1",
"@types/node": "^16.10.3",

View File

@@ -0,0 +1,210 @@
/**
* Rule: no-signozhq-ui-barrel
*
* Forbids importing from the `@signozhq/ui` barrel and requires the matching
* subpath instead.
*
* This rule catches:
* import { Typography } from '@signozhq/ui'
* import { Button, toast } from '@signozhq/ui'
* import '@signozhq/ui'
*
* And expects:
* import { Typography } from '@signozhq/ui/typography'
* import { Button } from '@signozhq/ui/button'
* import { toast } from '@signozhq/ui/sonner'
*
* Why: the barrel eagerly require()s every component (~90 of them) along with
* their Radix/cmdk/motion/react-day-picker dependencies. Under Jest this caused
* 5s timeouts and flaky tests after the Antd→@signozhq/ui Typography migration
* (#11199). Subpath imports (added in @signozhq/ui@0.0.18) load only what's
* used.
*
* The auto-generated `auto-import-registry.d.ts` is a pure declaration file
* that exists solely to nudge VS Code's auto-import indexer; its bare
* `import '@signozhq/ui';` is type-only and not emitted, so it is exempt.
*
* Autofix:
* Rewrites named imports to the matching subpath, splitting one statement
* into multiple when specifiers come from different subpaths. The
* export-name → subpath map is derived lazily from the installed
* `@signozhq/ui` dist `.d.ts` files. Imports we can't classify (namespace,
* default, side-effect, or unknown specifier) are reported without a fix.
*/
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ALLOWED_FILES = new Set(['auto-import-registry.d.ts']);
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
let exportMap = null;
function loadExportMap() {
if (exportMap === null) {
exportMap = buildExportMap();
}
return exportMap;
}
function buildExportMap() {
const map = new Map();
const root = findSignozUiRoot();
if (!root) return map;
let pkg;
try {
pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
} catch {
return map;
}
const subpathKeys = Object.keys(pkg.exports || {}).filter((k) => k !== '.');
for (const key of subpathKeys) {
const subpath = key.replace(/^\.\//, '');
const entry = join(root, 'dist', subpath, 'index.d.ts');
if (!existsSync(entry)) continue;
const names = new Set();
collectExportedNames(entry, names, new Set());
// First-wins: package.json subpath order is the canonical home for
// names re-exported across multiple subpaths (e.g. `ToggleColor` is
// declared in `toggle` and re-exported from `toggle-group`).
for (const name of names) {
if (!map.has(name)) map.set(name, subpath);
}
}
return map;
}
function findSignozUiRoot() {
let dir = PLUGIN_DIR;
while (true) {
const candidate = join(dir, 'node_modules', '@signozhq', 'ui');
if (existsSync(join(candidate, 'package.json'))) return candidate;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function collectExportedNames(filepath, out, visited) {
if (visited.has(filepath) || !existsSync(filepath)) return;
visited.add(filepath);
let content;
try {
content = readFileSync(filepath, 'utf-8');
} catch {
return;
}
// `export * from './x.js'` / `export type * from './x.js'`
for (const m of content.matchAll(
/export\s+(?:type\s+)?\*\s+from\s+['"]([^'"]+)['"]/g,
)) {
collectExportedNames(resolveRelativeDts(filepath, m[1]), out, visited);
}
// `export { Foo, type Bar, Foo as Baz } from '...';` and `export { ... };`
for (const m of content.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
for (const item of m[1].split(',')) {
const cleaned = item.trim().replace(/^type\s+/, '');
if (!cleaned) continue;
const idMatch = cleaned.match(
/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/,
);
if (idMatch) out.add(idMatch[2] || idMatch[1]);
}
}
// `export (declare) const|let|var|function|class|enum|type|interface Foo`
for (const m of content.matchAll(
/export\s+(?:declare\s+)?(?:const|let|var|function|class|enum|type|interface)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
)) {
out.add(m[1]);
}
}
function resolveRelativeDts(fromFile, spec) {
const base = dirname(fromFile);
const stripped = spec.replace(/\.(js|mjs|cjs)$/, '');
const sibling = join(base, `${stripped}.d.ts`);
if (existsSync(sibling)) return sibling;
const indexed = join(base, stripped, 'index.d.ts');
if (existsSync(indexed)) return indexed;
return sibling;
}
function buildReplacement(node, map) {
const specifiers = node.specifiers || [];
if (specifiers.length === 0) return null;
for (const spec of specifiers) {
if (spec.type !== 'ImportSpecifier') return null;
if (spec.imported?.type !== 'Identifier') return null;
}
const quote = node.source.raw?.[0] === '"' ? '"' : "'";
const topLevelType = node.importKind === 'type';
const keyword = topLevelType ? 'import type' : 'import';
const groups = new Map();
for (const spec of specifiers) {
const importedName = spec.imported.name;
const subpath = map.get(importedName);
if (!subpath) return null;
const localName = spec.local.name;
const inlineType = !topLevelType && spec.importKind === 'type';
let text = inlineType ? 'type ' : '';
text += importedName;
if (localName !== importedName) text += ` as ${localName}`;
if (!groups.has(subpath)) groups.set(subpath, []);
groups.get(subpath).push(text);
}
const lines = [];
for (const [subpath, items] of groups) {
lines.push(
`${keyword} { ${items.join(', ')} } from ${quote}@signozhq/ui/${subpath}${quote};`,
);
}
return lines.join('\n');
}
export default {
meta: {
fixable: 'code',
},
create(context) {
const filename = context.filename || '';
const basename = filename.split(/[\\/]/).pop();
if (ALLOWED_FILES.has(basename)) {
return {};
}
return {
ImportDeclaration(node) {
if (node.source.value !== '@signozhq/ui') {
return;
}
const replacement = buildReplacement(node, loadExportMap());
const report = {
node: node.source,
message:
"Do not import from the '@signozhq/ui' barrel. Use the matching subpath instead (e.g. '@signozhq/ui/typography', '@signozhq/ui/button', '@signozhq/ui/sonner'). The barrel eagerly loads ~90 components and slows tests substantially.",
};
if (replacement) {
report.fix = (fixer) => fixer.replaceText(node, replacement);
}
context.report(report);
},
};
},
};

View File

@@ -10,6 +10,7 @@ import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
export default {
meta: {
@@ -21,5 +22,6 @@ export default {
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
'no-raw-absolute-path': noRawAbsolutePath,
'no-antd-components': noAntdComponents,
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
},
};

View File

@@ -340,6 +340,9 @@ importers:
rehype-raw:
specifier: 7.0.0
version: 7.0.0
remark-gfm:
specifier: ^3.0.1
version: 3.0.1
rollup-plugin-visualizer:
specifier: 7.0.0
version: 7.0.0(rolldown@1.0.0-beta.53)
@@ -1904,105 +1907,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2357,56 +2344,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -2509,56 +2488,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
@@ -2613,42 +2584,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -3509,28 +3474,24 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
@@ -4305,49 +4266,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4414,7 +4367,7 @@ packages:
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
vite: npm:rolldown-vite@7.3.1
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -7241,28 +7194,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -10290,7 +10239,7 @@ packages:
oxlint: '>=1'
stylelint: '>=16'
typescript: '*'
vite: '>=5.4.21'
vite: npm:rolldown-vite@7.3.1
vls: '*'
vti: '*'
vue-tsc: ~2.2.10 || ^3.0.0
@@ -10319,12 +10268,12 @@ packages:
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: '>=2.0.0'
vite: npm:rolldown-vite@7.3.1
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
vite: '>=2.0.0'
vite: npm:rolldown-vite@7.3.1
vite-plugin-image-optimizer@2.0.3:
resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==}
@@ -10332,7 +10281,7 @@ packages:
peerDependencies:
sharp: '>=0.34.0'
svgo: '>=4'
vite: '>=5'
vite: npm:rolldown-vite@7.3.1
peerDependenciesMeta:
sharp:
optional: true
@@ -10342,7 +10291,7 @@ packages:
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: '*'
vite: npm:rolldown-vite@7.3.1
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}

View File

@@ -17,6 +17,12 @@ registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTE
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi

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,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -99,6 +102,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

@@ -164,14 +164,17 @@ function createMockAppContext(
featureFlags: [],
orgPreferences: createMockOrgPreferences(),
userPreferences: [],
hostsData: null,
isLoggedIn: true,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
featureFlagsFetchError: null,
orgPreferencesFetchError: null,
changelog: null,

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,13 +61,21 @@ function App(): JSX.Element {
org,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { hostname, pathname } = window.location;
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
const enableAnalytics = useCallback(
(user: IUser): void => {
// wait for the required data to be loaded before doing init for anything!
@@ -212,6 +221,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 +251,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

@@ -324,3 +324,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,
@@ -507,6 +508,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 = {

View File

@@ -0,0 +1,80 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
/** Path-only base for the AI Assistant API. */
export const AI_API_PATH = '/api/v1/assistant';
/** Header that tells the AI backend which SigNoz instance to query against. */
export const SIGNOZ_URL_HEADER = 'X-SigNoz-URL';
/**
* Sets `X-SigNoz-URL` on every outgoing AI Assistant request. The backend
* needs the originating SigNoz instance URL for multi-tenant deployments;
* when omitted it falls back to its `SIGNOZ_API_URL` env var.
*/
export const interceptorsRequestSigNozUrl = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
if (value.headers) {
value.headers[SIGNOZ_URL_HEADER] = getSigNozInstanceUrl();
}
return value;
};
/**
* 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 {
if (aiBackendUrl === url) {
return;
}
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.request.use(interceptorsRequestSigNozUrl);
AIAssistantInstance.interceptors.response.use(
interceptorsResponse,
interceptorRejected,
);

View File

@@ -0,0 +1,543 @@
/**
* 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/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,
RegenerateResponseDTO,
StatusEventDTO,
ThinkingEventDTO,
ThreadDetailResponseDTO,
ThreadListResponseDTO,
ThreadSummaryDTO,
ToolCallEventDTO,
ToolResultEventDTO,
} from './sigNozAIAssistantAPI.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AIAssistantInstance,
getAIBaseUrl,
SIGNOZ_URL_HEADER,
} from '../AIAPIInstance';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
// ---------------------------------------------------------------------------
// 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;
}
// Backoff schedule for 429 retries on SSE open. Three attempts is enough to
// absorb the brief window between cancel→send→stream when the backend is
// rate-limiting the burst, without making real "you're saturated" errors
// take forever to surface.
const SSE_429_BACKOFF_MS = [400, 1200, 2500];
function parseRetryAfterMs(value: string | null): number | null {
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const date = Date.parse(value);
if (Number.isFinite(date)) {
return Math.max(0, date - Date.now());
}
return null;
}
async function fetchSSEWithAuth(
url: string,
signal?: AbortSignal,
): Promise<Response> {
const send = async (token: string | null): Promise<Response> => {
const headers: Record<string, string> = {
[SIGNOZ_URL_HEADER]: getSigNozInstanceUrl(),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return fetch(url, { headers, signal });
};
const sendWithAuth = async (): Promise<Response> => {
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);
};
let res = await sendWithAuth();
for (const baseDelay of SSE_429_BACKOFF_MS) {
if (res.status !== 429 || signal?.aborted) {
return res;
}
const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
const delay = retryAfter ?? baseDelay;
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('SSE 429 backoff aborted', 'AbortError'));
},
{ once: true },
);
});
// eslint-disable-next-line no-await-in-loop
res = await sendWithAuth();
}
return res;
}
// ---------------------------------------------------------------------------
// 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();
}
}
/**
* Thrown by `streamEvents` when the SSE open returns a non-2xx response.
* Carries the HTTP status so callers can branch on rate-limit vs. other
* failures (e.g. show a "please wait a moment" message on 429).
*/
export class SSEStreamError extends Error {
status: number;
constructor(status: number, statusText: string) {
super(`SSE stream failed: ${status} ${statusText}`);
this.name = 'SSEStreamError';
this.status = status;
}
}
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 SSEStreamError(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;
}
/**
* Clean-slate regeneration of an assistant response. The backend rewinds the
* conversation up to (excluding) the supplied messageId and starts a fresh
* execution. Returns the new executionId — open an SSE stream for it the
* same way `sendMessage` and `approve` do.
*/
export async function regenerateMessage(
messageId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<RegenerateResponseDTO>(
`/messages/${messageId}/regenerate`,
undefined,
{ 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,
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,14 @@ import type {
import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNamespacesDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListDeployments200,
ListHosts200,
ListNamespaces200,
ListNodes200,
@@ -114,6 +116,90 @@ export const useListClusters = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* @summary List Deployments for Infra Monitoring
*/
export const listDeployments = (
inframonitoringtypesPostableDeploymentsDTO: BodyType<InframonitoringtypesPostableDeploymentsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDeployments200>({
url: `/api/v2/infra_monitoring/deployments`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableDeploymentsDTO,
signal,
});
};
export const getListDeploymentsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
> => {
const mutationKey = ['listDeployments'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listDeployments>>,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> }
> = (props) => {
const { data } = props ?? {};
return listDeployments(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListDeploymentsMutationResult = NonNullable<
Awaited<ReturnType<typeof listDeployments>>
>;
export type ListDeploymentsMutationBody =
BodyType<InframonitoringtypesPostableDeploymentsDTO>;
export type ListDeploymentsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Deployments for Infra Monitoring
*/
export const useListDeployments = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
> => {
const mutationOptions = getListDeploymentsMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring

View File

@@ -4628,6 +4628,83 @@ export interface InframonitoringtypesClustersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
/**
* @nullable
*/
export type InframonitoringtypesDeploymentRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesDeploymentRecordDTO {
/**
* @type integer
*/
availablePods: number;
/**
* @type number
* @format double
*/
deploymentCPU: number;
/**
* @type number
* @format double
*/
deploymentCPULimit: number;
/**
* @type number
* @format double
*/
deploymentCPURequest: number;
/**
* @type number
* @format double
*/
deploymentMemory: number;
/**
* @type number
* @format double
*/
deploymentMemoryLimit: number;
/**
* @type number
* @format double
*/
deploymentMemoryRequest: number;
/**
* @type string
*/
deploymentName: string;
/**
* @type integer
*/
desiredPods: number;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesDeploymentRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesHostFilterDTO {
/**
* @type string
@@ -4973,6 +5050,34 @@ export interface InframonitoringtypesPostableClustersDTO {
start: number;
}
export interface InframonitoringtypesPostableDeploymentsDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableHostsDTO {
/**
* @type integer
@@ -9509,6 +9614,14 @@ export type ListClusters200 = {
status: string;
};
export type ListDeployments200 = {
data: InframonitoringtypesDeploymentsDTO;
/**
* @type string
*/
status: string;
};
export type ListHosts200 = {
data: InframonitoringtypesHostsDTO;
/**

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

@@ -40,6 +40,7 @@ const getTraceV3 = async (
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),

View File

@@ -103,7 +103,7 @@ function EditMemberDrawer({
const { user: currentUser } = useAppContext();
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRole, setLocalRole] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -141,7 +141,7 @@ function EditMemberDrawer({
} = useRoles();
const {
fetchedRoleIds,
currentRoles: currentMemberRoles,
isLoading: isMemberRolesLoading,
applyDiff,
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
@@ -188,16 +188,24 @@ function EditMemberDrawer({
if (!member?.id) {
roleSessionRef.current = null;
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
setLocalRole(fetchedRoleIds[0] ?? '');
setLocalRoles(
currentMemberRoles.map((r) => r.id).filter(Boolean) as string[],
);
roleSessionRef.current = member.id;
}
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
}, [member?.id, currentMemberRoles, isMemberRolesLoading]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
localRole !== (fetchedRoleIds[0] ?? ''));
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentMemberRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
@@ -272,7 +280,14 @@ function EditMemberDrawer({
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
const rolesChanged =
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentMemberRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
);
const namePromise = nameChanged
? isSelf
@@ -286,7 +301,7 @@ function EditMemberDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged
? applyDiff([localRole].filter(Boolean), availableRoles)
? applyDiff([...localRoles], availableRoles)
: Promise.resolve([]),
]);
@@ -305,10 +320,7 @@ function EditMemberDrawer({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
const failures = await applyDiff([...localRoles], availableRoles);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
@@ -353,9 +365,9 @@ function EditMemberDrawer({
isDirty,
isSelf,
localDisplayName,
localRole,
localRoles,
fetchedDisplayName,
fetchedRoleIds,
currentMemberRoles,
updateMyUser,
updateUser,
applyDiff,
@@ -503,10 +515,15 @@ function EditMemberDrawer({
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="edit-member-drawer__email-text"></span>
)}
@@ -517,14 +534,15 @@ function EditMemberDrawer({
) : (
<RolesSelect
id="member-role"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role ?? '');
value={localRoles}
onChange={(roles): void => {
setLocalRoles(roles);
setSaveErrors((prev) =>
prev.filter(
(err) =>
@@ -532,8 +550,7 @@ function EditMemberDrawer({
),
);
}}
placeholder="Select role"
allowClear={false}
placeholder="Select roles"
/>
)}
</div>

View File

@@ -5,7 +5,9 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useGetRolesByUserID,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
@@ -23,11 +25,16 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
],
}));
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
@@ -98,6 +105,7 @@ jest.mock('react-use', () => ({
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockRemoveMutateAsync = jest.fn();
const mockCreateTokenMutateAsync = jest.fn();
const showErrorModal = jest.fn();
@@ -186,6 +194,14 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
@@ -296,7 +312,7 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('selecting a different role calls setRole with the new role name', async () => {
it('adding a new role calls setRole without removing existing ones', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
@@ -308,7 +324,7 @@ describe('EditMemberDrawer', () => {
renderDrawer({ onComplete });
// Open the roles dropdown and select signoz-editor
// signoz-admin is already selected; add signoz-editor on top
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
@@ -321,34 +337,31 @@ describe('EditMemberDrawer', () => {
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('does not call removeRole when the role is changed', async () => {
it('deselecting a role calls removeRole with the role id', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Switch from signoz-admin to signoz-viewer using single-select
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
// signoz-admin appears as a selected tag — click its remove button to deselect
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-viewer' },
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});

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(--accent-primary);
}
.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, Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
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 '@signozhq/icons';
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,46 @@ 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="solid"
color="secondary"
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={<Sparkles size={14} color="var(--primary)" />}
>
AI Assistant
</Button>
</Tooltip>
</div>
)}
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"
@@ -83,12 +130,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,9 +153,10 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
variant="ghost"
size="icon"
aria-label="Announcements"
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
prefix={<Inbox size={14} />}
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
@@ -130,12 +179,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

@@ -44,7 +44,11 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return <Badge color={color}>{statusCode}</Badge>;
return (
<Badge color={color} variant="outline">
{statusCode}
</Badge>
);
}
export default HttpStatusBadge;

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

@@ -38,6 +38,8 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
}

View File

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

@@ -3,5 +3,7 @@ export const USER_PREFERENCES = {
NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
};

View File

@@ -0,0 +1,102 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
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,98 @@
.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: var(--radius-2);
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);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
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(--accent-primary) !important;
}

View File

@@ -0,0 +1,209 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import HistorySidebar from '../components/ConversationsList';
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,
);
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],
);
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}>
<Sparkles size={16} color="var(--primary)" />
<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,60 @@
.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(--accent-primary);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
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,189 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import ConversationsList from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
import styles from './AIAssistantPanel.module.scss';
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
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);
useLayoutEffect(() => {
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
document.body.classList.toggle(
AI_ASSISTANT_PANEL_OPEN_CLASS,
shouldOffsetChatSupport,
);
if (shouldOffsetChatSupport) {
document.body.style.setProperty(
AI_ASSISTANT_PANEL_WIDTH_VAR,
`${panelWidth}px`,
);
} else {
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
}
return (): void => {
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
};
}, [isFullScreenPage, isOpen, panelWidth]);
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}>
<Sparkles size={18} color="var(--primary)" />
<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 ? (
<ConversationsList 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(--accent-primary);
color: var(--accent-primary-foreground);
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,45 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
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);
}
.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);
text-align: center;
&.compact {
padding: 8px 12px;
}
}

View File

@@ -0,0 +1,155 @@
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-assistant/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,
});
// Cover the gap between rehydrate (empty primed entry) and the first
// loadThread response. `isHydrating` is set on the rehydrated conversation
// and cleared once fetchThreads resolves; `isLoadingThread` covers the
// per-thread fetch that follows. Together they keep the skeleton visible
// for persisted chats without flashing it on freshly-created ones.
const isHydrating = Boolean(conversation?.isHydrating);
if ((isLoadingThread || isHydrating) && 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,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(--l3-border) 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(--l3-border);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-foreground);
}
}

View File

@@ -0,0 +1,116 @@
.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: var(--radius-2);
// 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);
}
.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: var(--radius-2);
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);
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&.error {
border-color: var(--accent-cherry);
color: var(--accent-cherry);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground);
}
&:hover:not(:disabled) svg {
color: var(--accent-primary);
}
&.error svg {
color: var(--accent-cherry);
}
}
.spin {
color: var(--accent-primary) !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);
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,537 @@
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 } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
restoreExecution,
revertExecution,
undoExecution,
} from 'api/ai-assistant/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;
}
}
/**
* 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 => {
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={12} 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);
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}
>
<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,282 @@
@use '../../_scrollbar' as *;
.card {
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
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: var(--radius-2);
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: var(--radius-2);
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: color-mix(in srgb, var(--accent-forest), transparent 88%);
color: var(--l1-foreground);
.diffGutter {
color: var(--accent-forest);
}
}
.diffLineRemove {
background: color-mix(in srgb, var(--accent-cherry), transparent 88%);
color: var(--l1-foreground);
.diffGutter {
color: var(--accent-cherry);
}
}
// 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: color-mix(in srgb, var(--l3-foreground), transparent 94%);
min-height: 18px;
}
.diffGutter {
flex-shrink: 0;
width: 12px;
text-align: center;
font-weight: 600;
user-select: none;
color: var(--l3-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(--accent-cherry);
}
&.after .diffLabel {
color: var(--accent-forest);
}
}
.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: var(--radius-2);
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(--accent-forest);
}
&.no {
color: var(--l3-foreground);
}
}
.statusText {
font-size: 13px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,471 @@
import { useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogHeader,
DialogSubtitle,
DialogTitle,
} from '@signozhq/ui/dialog';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import type {
ApprovalEventDTO,
ApprovalEventDTODiff,
} from 'api/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,462 @@
@use '../../_scrollbar' as *;
.input {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--l1-background);
border-radius: var(--radius-2);
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: var(--radius-2);
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: var(--radius-2);
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: var(--radius-2);
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);
}
.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);
opacity: 0.6;
}
&:disabled {
opacity: 0.2;
cursor: not-allowed;
}
}
.charWarning {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
color: var(--accent-sienna);
background: color-mix(in srgb, var(--accent-sienna), transparent 90%);
border: 1px solid color-mix(in srgb, var(--accent-sienna), transparent 65%);
border-radius: var(--radius-2);
}
.sendBtn {
flex-shrink: 0;
border-radius: var(--radius-2);
&.stop {
background: var(--accent-cherry) !important;
border-color: var(--accent-cherry) !important;
&:hover {
opacity: 0.85;
}
}
}
.contextPopover {
width: 480px !important;
max-width: min(92vw, 480px);
margin-left: 16px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
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;
z-index: 1000;
}
.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 color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
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: color-mix(in srgb, var(--accent-primary), transparent 86%);
color: var(--l1-foreground);
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
}
}
.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 color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
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: color-mix(in srgb, var(--accent-primary), transparent 86%);
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
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);
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(--accent-cherry);
color: var(--accent-cherry-foreground);
transition: opacity 0.12s;
&:hover {
opacity: 0.85;
}
}

View File

@@ -0,0 +1,944 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import cx from 'classnames';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Tooltip } from '@signozhq/ui/tooltip';
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-assistant/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 = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
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,
permission: micPermission,
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));
}
},
});
const showMic = isSupported && micPermission !== 'denied';
// 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 || micPermission === 'denied') {
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,
micPermission,
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}>
{showMic &&
(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="Voice input">
<Button
variant="ghost"
size="icon"
onClick={start}
disabled={disabled}
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,133 @@
.clarification {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
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(--accent-primary);
}
.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(--accent-forest);
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(--accent-cherry);
margin-left: 2px;
}
.input,
.select {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 5px 8px;
font-size: 13px;
color: var(--l1-foreground);
outline: none;
transition: border-color 0.12s;
&:focus {
border-color: var(--accent-primary);
}
}
.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(--accent-primary);
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 } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
ClarificationEventDTO,
ClarificationFieldEventDTO,
} from 'api/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,145 @@
.item {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border-radius: var(--radius-2);
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);
}
}
}
.icon {
flex-shrink: 0;
color: var(--l3-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);
opacity: 0.7;
}
.input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--accent-primary);
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: var(--radius-2);
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(--accent-cherry) !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(--accent-amber) !important;
svg {
color: inherit !important;
}
}
.restoreItem {
color: var(--primary) !important;
svg {
color: inherit !important;
}
}

View File

@@ -0,0 +1,226 @@
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 } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
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 {
if (!Number.isFinite(ts)) {
return '';
}
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()}
>
<DropdownMenuSimple
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} />}
/>
</DropdownMenuSimple>
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,84 @@
.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: var(--radius-2);
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(--accent-primary) 18%, transparent);
border-bottom-right-radius: var(--radius-2);
color: var(--l1-foreground);
}
&.assistant {
max-width: 95%;
background: var(--l2-background);
border-bottom-left-radius: var(--radius-2);
}
}
.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,136 @@
@use '../../_scrollbar' as *;
.conversationsList {
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: 8px 12px;
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: 24px;
}
.searchBar {
padding: 0px 8px 12px;
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);
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);
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);
}

View File

@@ -0,0 +1,234 @@
import { useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Conversation } from '../../types';
import { useVariant } from '../../VariantContext';
import ConversationItem from '../ConversationItem';
import styles from './ConversationsList.module.scss';
interface ConversationsListProps {
/** Called when a conversation is selected — lets the parent navigate if needed */
onSelect?: (id: string) => void;
onNewConversation?: () => void;
showAddNewConversation?: boolean;
}
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 ConversationsList({
onSelect,
onNewConversation,
showAddNewConversation = false,
}: ConversationsListProps): 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.conversationsList, variantClass)}>
<div className={styles.header}>
<span className={styles.heading}>Conversations</span>
{isLoadingThreads && <HeaderLoadingDots />}
{!isLoadingThreads && showAddNewConversation && (
<Tooltip title="New conversation">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={onNewConversation}
aria-label="New conversation"
>
<Plus size={12} />
</Button>
</Tooltip>
)}
</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 './ConversationsList';
export { default } from './ConversationsList';

View File

@@ -0,0 +1,327 @@
.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: var(--radius-2);
padding: 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 153.846% */
letter-spacing: -0.065px;
max-width: 100%;
.user & {
background: var(--accent-primary);
color: var(--primary-foreground);
border-bottom-right-radius: var(--radius-2);
}
.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: var(--radius-2);
}
}
.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: 12px 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(--accent-primary);
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: var(--radius-2);
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: var(--radius-2);
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: var(--radius-2);
object-fit: cover;
}
.attachmentFile {
font-size: 11px;
padding: 3px 8px;
border-radius: var(--radius-2);
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,164 @@
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 UserMessageActions from '../UserMessageActions';
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,
displayText: block.displayText,
}}
/>
);
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}
/>
)}
{isUser && <UserMessageActions message={message} />}
</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,89 @@
.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) !important;
&:hover {
color: var(--l1-foreground) !important;
}
&.active {
color: var(--accent-forest) !important;
}
&.votedUp {
color: var(--accent-primary) !important;
}
&.votedDown {
color: var(--accent-cherry) !important;
}
}
.time {
font-size: 10px;
color: var(--l3-foreground);
white-space: nowrap;
padding-left: 2px;
border-left: 1px solid var(--l2-border);
}
.feedbackTextarea {
width: 100%;
min-height: 96px;
padding: 10px 12px;
resize: vertical;
font: inherit;
font-size: 13px;
line-height: 1.5;
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
box-sizing: border-box;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px var(--accent-primary);
}
}
.feedbackDialogFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Tooltip } from '@signozhq/ui/tooltip';
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,
);
// Negative-feedback dialog: collects an optional comment from the user.
// Positive feedback is one-click; negative requires explicit Submit so
// users can describe what was wrong.
const [isNegativeDialogOpen, setIsNegativeDialogOpen] = useState(false);
const [negativeComment, setNegativeComment] = useState('');
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;
}
if (rating === 'negative') {
setNegativeComment('');
setIsNegativeDialogOpen(true);
return;
}
setVote(rating);
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback],
);
const handleSubmitNegative = useCallback((): void => {
setVote('negative');
setIsNegativeDialogOpen(false);
submitMessageFeedback(
message.id,
'negative',
negativeComment.trim() || undefined,
);
}, [message.id, negativeComment, submitMessageFeedback]);
return (
<>
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
variant="ghost"
onClick={handleCopy}
color="secondary"
>
{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"
color="secondary"
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"
color="secondary"
onClick={(): void => handleVote('negative')}
>
<ThumbsDown size={12} />
</Button>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate">
<Button
className={styles.btn}
size="icon"
variant="ghost"
color="secondary"
onClick={onRegenerate}
>
<RefreshCw size={12} />
</Button>
</Tooltip>
)}
</div>
<span className={styles.time}>
{relativeTime} · {absoluteTime}
</span>
</div>
<DialogWrapper
open={isNegativeDialogOpen}
onOpenChange={setIsNegativeDialogOpen}
title="What went wrong?"
subTitle="Your feedback helps us improve the assistant. Comments are optional."
width="base"
footer={
<div className={styles.feedbackDialogFooter}>
<Button
variant="solid"
color="secondary"
onClick={(): void => setIsNegativeDialogOpen(false)}
>
Cancel
</Button>
<Button variant="solid" color="primary" onClick={handleSubmitNegative}>
Send feedback
</Button>
</div>
}
>
<textarea
className={styles.feedbackTextarea}
placeholder="Tell us what was unhelpful, inaccurate, or unsafe…"
value={negativeComment}
onChange={(e): void => setNegativeComment(e.target.value)}
rows={5}
autoFocus
maxLength={2000}
/>
</DialogWrapper>
</>
);
}

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);
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/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,45 @@
// 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);
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
}
.label {
font-weight: 400;
}
.body {
padding: 4px 0 4px 22px;
}
.content {
font-size: 12px;
color: var(--l3-foreground);
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,99 @@
// 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);
user-select: none;
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
&.spin {
color: var(--accent-primary);
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 {
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);
}
.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: var(--radius-2);
padding: 5px 7px;
margin: 0;
overflow-x: auto;
white-space: pre;
max-height: 160px;
}

View File

@@ -0,0 +1,69 @@
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, displayText } = toolCall;
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
// it's the human-friendly title the backend wants surfaced. Fall back
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
// the field is empty / null / missing.
const label =
displayText && displayText.trim().length > 0
? displayText
: 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,31 @@
.actions {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 2px 0;
// User bubbles are right-aligned; mirror the alignment so the chips
// hug the bubble's right edge.
align-self: flex-end;
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover).
opacity: var(--feedback-opacity, 0);
transition: opacity 0.15s ease;
}
.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) !important;
&:hover {
color: var(--l1-foreground) !important;
}
&.active {
color: var(--accent-forest) !important;
}
}

View File

@@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import { Message } from '../../types';
import styles from './UserMessageActions.module.scss';
interface UserMessageActionsProps {
message: Message;
}
/**
* Action row rendered under user message bubbles. Mirrors the assistant
* feedback strip's hover-reveal treatment via the bubble's
* `--feedback-opacity` CSS variable; intentionally minimal for now —
* additional actions (edit, share, …) can slot in alongside the copy chip.
*/
export default function UserMessageActions({
message,
}: UserMessageActionsProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback((): void => {
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
return (
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
variant="ghost"
color="secondary"
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default } from './UserMessageActions';

View File

@@ -0,0 +1,87 @@
@use '../../_scrollbar' as *;
.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);
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: var(--radius-2);
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);
color: var(--l1-foreground);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground);
}
&:hover svg {
color: var(--accent-primary);
}
}

View File

@@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import {
Activity,
TriangleAlert,
ChartBar,
Search,
Zap,
Sparkles,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../../types';
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 regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
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 handleRegenerate = useCallback(
(messageId: string): void => {
if (isStreaming) {
return;
}
void regenerateAssistantMessage(conversationId, messageId);
},
[conversationId, isStreaming, regenerateAssistantMessage],
);
// 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}>
<Sparkles size={24} color="var(--primary)" />
</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
? (): void => handleRegenerate(msg.id)
: 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(--accent-amber);
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);
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(--accent-forest);
}
&.no {
color: var(--l3-foreground);
}
&.err {
color: var(--accent-cherry);
}
}
.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/button';
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,35 @@
.block {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 12px 14px;
margin: 8px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.title {
margin: 0 0 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.unit {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.empty {
margin: 0;
font-size: 12px;
color: var(--l3-foreground);
}

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