mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-13 21:50:31 +01:00
Compare commits
36 Commits
issue_4360
...
mute-rules
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c2e8366e | ||
|
|
17f0c33fe0 | ||
|
|
c31d95f9c2 | ||
|
|
2b46e35bb0 | ||
|
|
a1e3d75296 | ||
|
|
1bbccebf16 | ||
|
|
0304c86a6e | ||
|
|
6994a3875a | ||
|
|
2d3625bc60 | ||
|
|
931988fcca | ||
|
|
f78bfa1d57 | ||
|
|
20c08b30b3 | ||
|
|
46d69f6ddb | ||
|
|
c8caa657e0 | ||
|
|
31fee32601 | ||
|
|
2362a1d6ec | ||
|
|
023e916446 | ||
|
|
270b05f94d | ||
|
|
e4e40103d5 | ||
|
|
35a498010d | ||
|
|
ed21a51671 | ||
|
|
742b0b86b4 | ||
|
|
13e1b254b5 | ||
|
|
dc579c1001 | ||
|
|
95b7186086 | ||
|
|
096ded8b0e | ||
|
|
14082ee5c7 | ||
|
|
5f36ca2992 | ||
|
|
f0cedec7a8 | ||
|
|
007fedf379 | ||
|
|
4831bea47e | ||
|
|
217ac56cef | ||
|
|
15a1d31844 | ||
|
|
02ab513081 | ||
|
|
7be19bd773 | ||
|
|
084168dad7 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.123.0
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.123.0
|
||||
image: signoz/signoz:v0.122.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.123.0}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
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.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.123.0}
|
||||
image: signoz/signoz:${VERSION:-v0.122.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
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.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -2579,76 +2579,6 @@ 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:
|
||||
@@ -2979,32 +2909,6 @@ 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:
|
||||
@@ -4898,6 +4802,8 @@ components:
|
||||
type: string
|
||||
kind:
|
||||
$ref: '#/components/schemas/RuletypesMaintenanceKind'
|
||||
labelExpression:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schedule:
|
||||
@@ -4925,6 +4831,8 @@ components:
|
||||
type: array
|
||||
description:
|
||||
type: string
|
||||
labelExpression:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schedule:
|
||||
@@ -4989,10 +4897,6 @@ components:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRepeatOn'
|
||||
@@ -5000,11 +4904,7 @@ components:
|
||||
type: array
|
||||
repeatType:
|
||||
$ref: '#/components/schemas/RuletypesRepeatType'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- startTime
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
@@ -5168,6 +5068,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
RuletypesScheduleType:
|
||||
enum:
|
||||
@@ -12072,81 +11973,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
|
||||
@@ -49,7 +48,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, tr)
|
||||
|
||||
// create ch rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
|
||||
|
||||
@@ -73,7 +72,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, pr)
|
||||
|
||||
// create promql rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
|
||||
// create anomaly rule
|
||||
@@ -96,7 +95,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, ar)
|
||||
|
||||
// create anomaly rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
@@ -210,9 +209,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// newTask returns an appropriate group for the rule type
|
||||
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
|
||||
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
|
||||
if taskType == baserules.TaskTypeCh {
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
|
||||
}
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: Prefer SigNoz UI and icons across frontend code
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# UI Components and Icons Source of Truth
|
||||
|
||||
For all frontend implementation work in this repository:
|
||||
|
||||
- Always use UI primitives/components from `@signozhq/ui`.
|
||||
- Always use icons from `@signozhq/icons`.
|
||||
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
|
||||
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
|
||||
|
||||
## Migration guidance
|
||||
|
||||
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
|
||||
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.
|
||||
@@ -291,8 +291,6 @@
|
||||
// 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",
|
||||
{
|
||||
@@ -497,8 +495,7 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/api/generated/**/*.ts",
|
||||
"src/api/ai-assistant/**/*.ts"
|
||||
"src/api/generated/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
|
||||
@@ -29,18 +29,6 @@ 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),
|
||||
|
||||
@@ -135,7 +135,6 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rollup-plugin-visualizer": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
@@ -174,18 +173,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",
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -10,7 +10,6 @@ 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: {
|
||||
@@ -22,6 +21,5 @@ export default {
|
||||
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
},
|
||||
};
|
||||
|
||||
69
frontend/pnpm-lock.yaml
generated
69
frontend/pnpm-lock.yaml
generated
@@ -340,9 +340,6 @@ 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)
|
||||
@@ -1907,89 +1904,105 @@ 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==}
|
||||
@@ -2344,48 +2357,56 @@ 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==}
|
||||
@@ -2488,48 +2509,56 @@ 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==}
|
||||
@@ -2584,36 +2613,42 @@ 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==}
|
||||
@@ -3474,24 +3509,28 @@ 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==}
|
||||
@@ -4266,41 +4305,49 @@ 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==}
|
||||
@@ -4367,7 +4414,7 @@ packages:
|
||||
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
@@ -7194,24 +7241,28 @@ 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==}
|
||||
@@ -10239,7 +10290,7 @@ packages:
|
||||
oxlint: '>=1'
|
||||
stylelint: '>=16'
|
||||
typescript: '*'
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: '>=5.4.21'
|
||||
vls: '*'
|
||||
vti: '*'
|
||||
vue-tsc: ~2.2.10 || ^3.0.0
|
||||
@@ -10268,12 +10319,12 @@ packages:
|
||||
vite-plugin-compression@0.5.1:
|
||||
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
||||
peerDependencies:
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: '>=2.0.0'
|
||||
|
||||
vite-plugin-html@3.2.2:
|
||||
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
|
||||
peerDependencies:
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: '>=2.0.0'
|
||||
|
||||
vite-plugin-image-optimizer@2.0.3:
|
||||
resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==}
|
||||
@@ -10281,7 +10332,7 @@ packages:
|
||||
peerDependencies:
|
||||
sharp: '>=0.34.0'
|
||||
svgo: '>=4'
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: '>=5'
|
||||
peerDependenciesMeta:
|
||||
sharp:
|
||||
optional: true
|
||||
@@ -10291,7 +10342,7 @@ packages:
|
||||
vite-tsconfig-paths@6.1.1:
|
||||
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
|
||||
peerDependencies:
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
vite: '*'
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
|
||||
@@ -17,12 +17,6 @@ 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
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -41,8 +40,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
} = useAppContext();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -102,10 +99,6 @@ 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;
|
||||
|
||||
|
||||
@@ -164,17 +164,14 @@ 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,
|
||||
|
||||
@@ -18,7 +18,6 @@ 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';
|
||||
@@ -61,21 +60,13 @@ function App(): JSX.Element {
|
||||
org,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
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!
|
||||
@@ -221,27 +212,6 @@ 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(() => {
|
||||
@@ -251,8 +221,7 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -324,10 +324,3 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -508,13 +507,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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,
|
||||
);
|
||||
@@ -1,543 +0,0 @@
|
||||
/**
|
||||
* AI Assistant API client.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /api/v1/assistant/threads → { threadId }
|
||||
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
|
||||
*
|
||||
* For subsequent messages in the same thread, repeat steps 2–3.
|
||||
* 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
@@ -13,14 +13,12 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableNamespacesDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
InframonitoringtypesPostableVolumesDTO,
|
||||
ListClusters200,
|
||||
ListDeployments200,
|
||||
ListHosts200,
|
||||
ListNamespaces200,
|
||||
ListNodes200,
|
||||
@@ -116,90 +114,6 @@ 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
|
||||
|
||||
@@ -4628,83 +4628,6 @@ 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
|
||||
@@ -5050,34 +4973,6 @@ 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
|
||||
@@ -7147,6 +7042,10 @@ export interface RuletypesPlannedMaintenanceDTO {
|
||||
*/
|
||||
id: string;
|
||||
kind: RuletypesMaintenanceKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
labelExpression?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7174,6 +7073,10 @@ export interface RuletypesPostablePlannedMaintenanceDTO {
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
labelExpression?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7247,23 +7150,12 @@ export interface RuletypesRecurrenceDTO {
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
* @nullable true
|
||||
*/
|
||||
endTime?: Date | null;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
repeatOn?: RuletypesRepeatOnDTO[] | null;
|
||||
repeatType: RuletypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
@@ -7445,7 +7337,7 @@ export interface RuletypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: Date;
|
||||
startTime: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9614,14 +9506,6 @@ export type ListClusters200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListDeployments200 = {
|
||||
data: InframonitoringtypesDeploymentsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListHosts200 = {
|
||||
data: InframonitoringtypesHostsDTO;
|
||||
/**
|
||||
|
||||
@@ -4,46 +4,14 @@ import {
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
|
||||
// generated API Instance
|
||||
const generatedAPIAxiosInstance = axios.create({
|
||||
baseURL: ENVIRONMENT.baseURL,
|
||||
});
|
||||
|
||||
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
|
||||
|
||||
export const setGeneratedAPIQueryKeyHeaderContext = <THeaders extends object>(
|
||||
headers?: THeaders,
|
||||
): void => {
|
||||
generatedAPIQueryKeyHeaderContext = headers
|
||||
? { ...(headers as Record<string, unknown>) }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const hashHeaderValue = (value: string): string => {
|
||||
let hash = 0;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
|
||||
return hash.toString(16);
|
||||
};
|
||||
|
||||
const mergeHeaderRecord = (
|
||||
target: Record<string, unknown>,
|
||||
source: unknown,
|
||||
): Record<string, unknown> => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return target;
|
||||
}
|
||||
|
||||
return Object.assign(target, source as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const GeneratedAPIInstance = <T>(
|
||||
config: AxiosRequestConfig,
|
||||
): Promise<T> => {
|
||||
@@ -58,59 +26,5 @@ generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
|
||||
const getDefaultQueryKeyHeaders = (): Record<string, unknown> => {
|
||||
const defaults = generatedAPIAxiosInstance.defaults
|
||||
.headers as unknown as Record<string, unknown>;
|
||||
const headers: Record<string, unknown> = {};
|
||||
const methodKeys = new Set([
|
||||
'common',
|
||||
'delete',
|
||||
'get',
|
||||
'head',
|
||||
'options',
|
||||
'patch',
|
||||
'post',
|
||||
'put',
|
||||
]);
|
||||
|
||||
mergeHeaderRecord(headers, defaults?.common);
|
||||
mergeHeaderRecord(headers, defaults?.get);
|
||||
|
||||
for (const [key, value] of Object.entries(defaults ?? {})) {
|
||||
if (!methodKeys.has(key)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getGeneratedAPIQueryKeyHeaders = <THeaders extends object>(
|
||||
headers?: THeaders,
|
||||
): [{ headers: Record<string, unknown> }] | [] => {
|
||||
const mergedHeaders = {
|
||||
...getDefaultQueryKeyHeaders(),
|
||||
...generatedAPIQueryKeyHeaderContext,
|
||||
...(headers as Record<string, unknown> | undefined),
|
||||
};
|
||||
|
||||
const queryKeyHeaders = Object.fromEntries(
|
||||
Object.entries(mergedHeaders)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => {
|
||||
if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
|
||||
return [key, hashHeaderValue(value)];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.keys(queryKeyHeaders).length
|
||||
? [{ headers: queryKeyHeaders }]
|
||||
: [];
|
||||
};
|
||||
|
||||
export type ErrorType<Error> = AxiosError<Error>;
|
||||
export type BodyType<BodyData> = BodyData;
|
||||
|
||||
@@ -40,7 +40,6 @@ 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),
|
||||
|
||||
@@ -4,49 +4,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--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;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Dot, Sparkles } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Popover } from 'antd';
|
||||
import { Button, 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';
|
||||
@@ -38,7 +29,6 @@ function HeaderRightSection({
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
@@ -77,46 +67,9 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const pendingUserInputCount: number = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
const showHeaderPendingBadge =
|
||||
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{isAIAssistantEnabled && !isDrawerOpen && (
|
||||
<div className="header-ai-assistant-btn-container">
|
||||
{showHeaderPendingBadge ? (
|
||||
<span className="header-ai-assistant-btn__badge" aria-hidden>
|
||||
<span className="header-ai-assistant-btn__pulse-dot">
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Tooltip title="AI Assistant">
|
||||
<Button
|
||||
variant="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"
|
||||
@@ -130,13 +83,12 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="share-feedback-btn"
|
||||
aria-label="Feedback"
|
||||
prefix={<SquarePen size={14} />}
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -153,10 +105,9 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Announcements"
|
||||
prefix={<Inbox size={14} />}
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -179,12 +130,12 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Share"
|
||||
prefix={<Globe size={14} />}
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
/>
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,6 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -44,11 +44,7 @@ function HttpStatusBadge({
|
||||
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge color={color}>{statusCode}</Badge>;
|
||||
}
|
||||
|
||||
export default HttpStatusBadge;
|
||||
|
||||
@@ -12,7 +12,6 @@ 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);
|
||||
@@ -32,4 +31,4 @@ SyntaxHighlighter.registerLanguage('yaml', yaml);
|
||||
SyntaxHighlighter.registerLanguage('yml', yaml);
|
||||
|
||||
export default SyntaxHighlighter;
|
||||
export { a11yDark, oneLight };
|
||||
export { a11yDark };
|
||||
|
||||
@@ -38,8 +38,6 @@ 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',
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ const ROUTES = {
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -3,7 +3,5 @@ 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',
|
||||
};
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AIAssistantDrawer';
|
||||
export { default } from './AIAssistantDrawer';
|
||||
@@ -1,98 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AIAssistantModal';
|
||||
export { default } from './AIAssistantModal';
|
||||
@@ -1,60 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AIAssistantPanel';
|
||||
export { default } from './AIAssistantPanel';
|
||||
@@ -1,32 +0,0 @@
|
||||
.trigger {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: var(--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);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AIAssistantTrigger';
|
||||
export { default } from './AIAssistantTrigger';
|
||||
@@ -1,53 +0,0 @@
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ChatInput, { autoContextKey } from '../components/ChatInput';
|
||||
import ConversationSkeleton from '../components/ConversationSkeleton';
|
||||
import VirtualizedMessages from '../components/VirtualizedMessages';
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { MessageAttachment } from '../types';
|
||||
import { MessageContext } from '../../../api/ai-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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ConversationView';
|
||||
export { default } from './ConversationView';
|
||||
@@ -1,8 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AIAssistantVariant = 'panel' | 'page' | 'modal';
|
||||
|
||||
export const VariantContext = createContext<AIAssistantVariant>('page');
|
||||
|
||||
export const useVariant = (): AIAssistantVariant => useContext(VariantContext);
|
||||
@@ -1,32 +0,0 @@
|
||||
@mixin scrollbar($width: 0.3rem) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: var(--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);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ActionsSection';
|
||||
export { default } from './ActionsSection';
|
||||
@@ -1,282 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ApprovalCard';
|
||||
export { default } from './ApprovalCard';
|
||||
@@ -1,462 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,944 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ChatInput';
|
||||
export { default } from './ChatInput';
|
||||
@@ -1,133 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ClarificationForm';
|
||||
export { default } from './ClarificationForm';
|
||||
@@ -1,145 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ConversationItem';
|
||||
export { default } from './ConversationItem';
|
||||
@@ -1,84 +0,0 @@
|
||||
.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%);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
|
||||
import styles from './ConversationSkeleton.module.scss';
|
||||
|
||||
/**
|
||||
* Each entry is one bubble in the placeholder thread:
|
||||
* role: who "sent" the bubble — drives left/right alignment + colour
|
||||
* lines: list of widths (as % of the bubble) for the shimmer lines inside
|
||||
*
|
||||
* Mixed widths and varying line counts produce something that scans as a real
|
||||
* back-and-forth conversation rather than a uniform grid.
|
||||
*/
|
||||
const ROWS: { role: 'user' | 'assistant'; lines: number[] }[] = [
|
||||
{ role: 'user', lines: [62] },
|
||||
{ role: 'assistant', lines: [85, 92, 70] },
|
||||
{ role: 'user', lines: [55, 40] },
|
||||
{ role: 'assistant', lines: [90, 78, 95, 60] },
|
||||
{ role: 'user', lines: [48] },
|
||||
{ role: 'assistant', lines: [80, 88] },
|
||||
];
|
||||
|
||||
/** Skeleton chat thread shown while a single conversation is being loaded. */
|
||||
export default function ConversationSkeleton(): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
|
||||
return (
|
||||
<div className={styles.thread} aria-busy aria-label="Loading conversation">
|
||||
{ROWS.map((row, idx) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={idx}
|
||||
className={cx(styles.message, styles[row.role], {
|
||||
[styles.compact]: isCompact,
|
||||
})}
|
||||
>
|
||||
<div className={cx(styles.bubble, styles[row.role])}>
|
||||
{row.lines.map((width, lineIdx) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={lineIdx}
|
||||
className={styles.line}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ConversationSkeleton';
|
||||
export { default } from './ConversationSkeleton';
|
||||
@@ -1,136 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ConversationsList';
|
||||
export { default } from './ConversationsList';
|
||||
@@ -1,327 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './MessageBubble';
|
||||
export { default } from './MessageBubble';
|
||||
@@ -1,13 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface MessageContextValue {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const MessageContext = createContext<MessageContextValue>({
|
||||
messageId: '',
|
||||
});
|
||||
|
||||
export const useMessageContext = (): MessageContextValue =>
|
||||
useContext(MessageContext);
|
||||
@@ -1,89 +0,0 @@
|
||||
.feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 2px 0;
|
||||
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover/visible).
|
||||
opacity: var(--feedback-opacity, 0);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.visible {
|
||||
--feedback-opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
color: var(--l3-foreground) !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;
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './MessageFeedback';
|
||||
export { default } from './MessageFeedback';
|
||||
@@ -1,9 +0,0 @@
|
||||
.streamingStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--l3-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ClarificationEventDTO,
|
||||
} from 'api/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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './StreamingMessage';
|
||||
export { default } from './StreamingMessage';
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ThinkingStep';
|
||||
export { default } from './ThinkingStep';
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ToolCallStep';
|
||||
export { default } from './ToolCallStep';
|
||||
@@ -1,31 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './UserMessageActions';
|
||||
@@ -1,87 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './VirtualizedMessages';
|
||||
export { default } from './VirtualizedMessages';
|
||||
@@ -1,104 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ActionBlock';
|
||||
export { default } from './ActionBlock';
|
||||
@@ -1,35 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
|
||||
|
||||
/**
|
||||
* Global registry for AI response block renderers.
|
||||
*
|
||||
* Any part of the application can register a custom block type:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*
|
||||
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
|
||||
* JSON payload, and the registered component will be rendered in-place:
|
||||
*
|
||||
* ```ai-my-panel
|
||||
* { "foo": "bar" }
|
||||
* ```
|
||||
*/
|
||||
const _registry = new Map<string, BlockComponent>();
|
||||
|
||||
export const BlockRegistry = {
|
||||
register<T>(type: string, component: BlockComponent<T>): void {
|
||||
_registry.set(type, component as BlockComponent);
|
||||
},
|
||||
|
||||
get(type: string): BlockComponent | undefined {
|
||||
return _registry.get(type);
|
||||
},
|
||||
|
||||
/** Returns all registered type names (useful for debugging). */
|
||||
types(): string[] {
|
||||
return Array.from(_registry.keys());
|
||||
},
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
.message {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.answered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.ok {
|
||||
color: var(--accent-forest);
|
||||
}
|
||||
&.no {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.answerText {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user