Merge pull request #2832 from arc53/shadcn-linter

Shadcn linter
This commit is contained in:
Alex authored and GitHub committed 2026-09-26 11:52:09 +01:00
commit 431a069d4c
400 files changed
+33500 -13850

No files matched your search

+5 -1
View File
@@ -207,6 +207,10 @@ vale .
- If shared state must be added, use Redux rather than introducing a new global state library.
- Avoid broad UI refactors unless the task explicitly asks for them.
- Do not re-create components if we already have some in the app.
- Follow `frontend/DESIGN.md`: compose `components/ui/` parts and pick their look with props, use theme tokens, and
keep typography, spacing, radius and motion on its roles.
- Every user-visible string, attributes included (`aria-label`, `label`, `placeholder`, `title`, `alt`), is a `t()`
key in all seven locales under `frontend/src/locale/` (`de en es jp ru zh zh-TW`). Admin pages stay English.
#### Icons
@@ -217,7 +221,7 @@ DocsGPT historically mixed three icon sources: `lucide-react`, inline SVG compon
plus, etc.). It tokenizes via `currentColor`, ships tree-shaken icons, and the codebase
already imports it in 30+ places. `<X className="size-4" />`, `<ChevronDown />`, etc.
2. **Use `assets/<name>.svg?react`** when you need a brand-specific or domain illustration
that doesn't exist in lucide (the app logo, robot fallback, retry arrow, send arrow,
that doesn't exist in lucide (the app logo, robot fallback, send arrow,
etc.). Always set `fill="currentColor"` / `stroke="currentColor"` in the SVG file so
consumers can theme via Tailwind text classes.
3. **Avoid `<img src={Asset}>` for new icons.** It blocks `currentColor` theming and
+1112
View File
File diff suppressed because it is too large. Load diff
+250 -8
View File
@@ -1,10 +1,11 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import react from 'eslint-plugin-react'
import unusedImports from 'eslint-plugin-unused-imports'
import prettier from 'eslint-plugin-prettier'
import globals from 'globals'
import js from '@eslint/js';
import { plugin as shadcn } from '@shadcn/lint';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import react from 'eslint-plugin-react';
import unusedImports from 'eslint-plugin-unused-imports';
import prettier from 'eslint-plugin-prettier';
import globals from 'globals';
export default [
{
@@ -49,6 +50,7 @@ export default [
react,
'unused-imports': unusedImports,
prettier,
shadcn,
},
rules: {
...js.configs.recommended.rules,
@@ -68,11 +70,251 @@ export default [
endOfLine: 'auto',
},
],
// The Radix Dialog wrapper is an internal primitive; app code uses
// <Modal> (or <CommandDialog>). Only src/components/ui may import it.
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/components/ui/dialog'],
message:
'Use <Modal> from @/components/ui/modal; the Dialog primitive is internal to ui/.',
},
],
},
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
'shadcn/no-restyle': [
'error',
{
allow: ['layout'],
contracts: [
{
pattern: '^Button$',
allow: ['layout'],
message: {
shape:
'"{{className}}" is not allowed on <Button>: use shape="pill" for round buttons. Other shapes need a new variant in {{file}}.',
spacing:
'"{{className}}" is not allowed on <Button>: use a size ({{sizes}}). shape="pill" already widens the padding; put space around the button on the parent (gap) or as margin here.',
color:
'"{{className}}" is not allowed on <Button>: use a variant ({{variants}}). Primary buttons already have white text; muted icon buttons are variant="ghost-muted"; bordered brand buttons are variant="outline-primary".',
typography:
'"{{className}}" is not allowed on <Button>: size="xs" gives text-xs; the default size is text-sm. Add a size in {{file}} only if the design explicitly calls for one.',
default:
'"{{className}}" is not allowed on <Button>: {{category}} belongs to the component. Use a variant ({{variants}}), a size ({{sizes}}) or a shape (default, pill). See DESIGN.md.',
},
},
{
pattern: '^Input$',
// font-mono: code fields (JSON, keys) keep a monospace face.
allow: [
'layout',
'text-left',
'text-center',
'text-right',
'font-mono',
],
message: {
default:
'"{{className}}" is not allowed on <Input>: use size (default, sm, lg, field), shape (default, pill) and variant (default, bare, filled); alignment classes and font-mono are allowed. Add a variant in {{file}} only if the design explicitly calls for one.',
},
},
{
pattern: '^SelectTrigger$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <SelectTrigger>: use size (sm, default, field), variant (default, ghost) and shape (default, pill) from {{file}}.',
},
},
{
pattern: '^Avatar$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Avatar>: use size (xs, sm, default, lg), shape (circle, square) and variant (primary, muted) from {{file}}.',
},
},
{
pattern: '^Card$',
allow: ['layout', 'gap-*'],
message: {
default:
'"{{className}}" is not allowed on <Card>: use variant (outline, filled, subtle), tone (destructive), padding (none, sm, default, lg) and interactive/selected from {{file}}.',
},
},
{
pattern: '^Textarea$',
// font-mono: code editors (JSON schema, workflow code) keep a
// monospace face.
allow: [
'layout',
'text-left',
'text-center',
'text-right',
'font-mono',
],
message: {
default:
'"{{className}}" is not allowed on <Textarea>: use size (sm, default, lg), resize (none, vertical, both) and variant (default, filled) from {{file}}; min-height and width are layout and allowed.',
},
},
{
pattern: '^Badge$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Badge>: use a variant ({{variants}}) from {{file}}.',
},
},
{
pattern: '^(Alert|AlertTitle|AlertDescription)$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, destructive, success, warning, info) from {{file}}.',
},
},
{
pattern: '^Checkbox$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Checkbox>: use size from {{file}}.',
},
},
{
pattern: '^Dropzone$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Dropzone>: use size (default, compact); colours, border and radius follow the drag state in {{file}}.',
},
},
{
pattern: '^(Tabs|TabsList|TabsTrigger|TabsContent)$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use the variants in {{file}}.',
},
},
{
pattern: '^Command(Input|List|Item|Group|Empty)?$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use the parts and the checked prop from {{file}}.',
},
},
{
pattern: '^Skeleton$',
allow: ['layout', 'rounded-*'],
},
{
pattern: '^Spinner$',
allow: ['layout', 'color'],
},
{
pattern: '^Progress$',
allow: ['layout'],
},
{
pattern: '^(Toast|ToastViewport)$',
allow: ['layout'],
},
{
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
allow: ['layout', 'spacing'],
},
{
// The primitive measures Content's padding-block for its scroll
// math, and the Viewport's top gap must scroll with the
// messages, so padding here is part of the layout.
pattern: '^MessageScroller(Viewport|Content)$',
allow: ['layout', 'spacing'],
},
{
pattern: '^OptionCard$',
allow: ['layout'],
},
{
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
},
{
pattern: '^(CardTitle|CardDescription)$',
allow: ['layout', 'typography', 'line-clamp-*'],
},
{
pattern: '^(TableCell|TableHeader)$',
allow: ['layout', 'typography', 'text-muted-foreground'],
},
{
pattern:
'^(Label|DialogTitle|DialogDescription|SheetTitle|SheetDescription)$',
allow: [
'layout',
'typography',
'text-muted-foreground',
'text-foreground',
],
},
{
// Modal wraps DialogContent and merges className after its p-8.
pattern:
'^((Dialog|Popover|Sheet|DropdownMenu|DropdownMenuSub|Select|Tooltip)Content|Sheet(Header|Footer)|Modal)$',
allow: ['layout', 'p-0'],
},
],
},
],
'shadcn/no-raw-colors': [
'error',
{
message:
'"{{className}}" uses the raw Tailwind palette. Map it to a token from {{file}}: grey text -> text-foreground or text-muted-foreground; grey borders -> border-border; grey fills -> bg-muted, bg-accent or bg-card; red -> destructive; green -> success; amber, yellow and orange -> warning; blue -> info; purple -> primary. Soft badge or alert fills use bg-<token>/10 and a single class replaces the light+dark pair. Status pills should be <Badge variant="...">.',
},
],
'shadcn/no-arbitrary-values': [
'error',
// Motion values (`transition-[color,box-shadow]`, custom easings)
// have no token scale; everything else must come from the theme.
{ allow: ['layout', 'transition-*', 'ease-*'] },
],
'shadcn/no-inline-styles': 'error',
'shadcn/no-unknown-classes': 'error',
'shadcn/require-static-classes': 'error',
},
settings: {
react: {
version: 'detect',
},
shadcn: {
note: 'See frontend/DESIGN.md for tokens, component variants and approved exceptions.',
},
},
},
]
{
// The design-system components define the styles; they are not restyled.
files: ['src/components/ui/**'],
rules: {
'shadcn/no-restyle': 'off',
'no-restricted-imports': 'off',
},
},
{
// Third-party renderers and canvas-positioned nodes take computed
// runtime values that Tailwind classes cannot express.
files: [
'src/components/MermaidRenderer.tsx',
'src/agents/workflow/nodes/**',
],
rules: {
'shadcn/no-inline-styles': 'off',
},
},
];
+417 -10
View File
@@ -8,10 +8,6 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "^0.3.1",
"chart.js": "^4.5.1",
@@ -20,13 +16,11 @@
"cmdk": "^1.1.1",
"copy-to-clipboard": "^4.0.2",
"d3-force": "^3.0.0",
"date-fns": "^4.4.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"prop-types": "^15.8.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -48,6 +42,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
@@ -847,6 +842,329 @@
"node": ">=22.12.0"
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz",
"integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz",
"integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz",
"integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz",
"integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz",
"integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz",
"integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz",
"integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz",
"integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz",
"integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz",
"integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz",
"integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz",
"integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz",
"integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz",
"integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz",
"integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz",
"integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz",
"integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz",
"integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz",
"integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -2786,6 +3104,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/@shadcn/lint": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@shadcn/lint/-/lint-0.2.0.tgz",
"integrity": "sha512-DTYr427BR8cDc0J0Y++u1Uh12NAYGmdPiqgy4wKSogi7RNEhFanIcfBMPRPUvkunt9QiJUfI7B28TW39MrPCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0",
"cn": "0.3.2"
},
"engines": {
"node": ">=20.19"
},
"optionalDependencies": {
"oxc-parser": "^0.148.0"
},
"peerDependencies": {
"@typescript-eslint/parser": ">=8.40.0",
"eslint": ">=9.30.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/parser": {
"optional": true
},
"eslint": {
"optional": true
}
}
},
"node_modules/@shadcn/react": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.3.1.tgz",
@@ -3703,7 +4050,6 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3713,7 +4059,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
@@ -4811,6 +5157,19 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/cn": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.3.2.tgz",
"integrity": "sha512-RKzY5Edavc8B2Ka6tjJecKFC4nm0p7lADY72xzICmiJSf4TkaxWXt0fHsib2P2G4FkFyXfSDi9oDtIBuCZvY2g==",
"dev": true,
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4938,7 +5297,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -9848,6 +10206,55 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oxc-parser": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz",
"integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@oxc-project/types": "^0.148.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/oxc-project"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.148.0",
"@oxc-parser/binding-android-arm64": "0.148.0",
"@oxc-parser/binding-darwin-arm64": "0.148.0",
"@oxc-parser/binding-darwin-x64": "0.148.0",
"@oxc-parser/binding-freebsd-x64": "0.148.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.148.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.148.0",
"@oxc-parser/binding-linux-arm64-musl": "0.148.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.148.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.148.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.148.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.148.0",
"@oxc-parser/binding-linux-x64-gnu": "0.148.0",
"@oxc-parser/binding-linux-x64-musl": "0.148.0",
"@oxc-parser/binding-openharmony-arm64": "0.148.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.148.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.148.0",
"@oxc-parser/binding-win32-x64-msvc": "0.148.0"
}
},
"node_modules/oxc-parser/node_modules/@oxc-project/types": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -11746,7 +12153,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+2 -6
View File
@@ -14,6 +14,7 @@
"test:watch": "vitest",
"lint": "eslint ./src --ext .jsx,.js,.ts,.tsx",
"lint-fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --fix",
"lint:design": "eslint ./src -f json | node scripts/design-lint-report.mjs",
"format": "prettier ./src --write",
"prepare": "cd .. && husky install frontend/.husky"
},
@@ -24,10 +25,6 @@
]
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@reduxjs/toolkit": "^2.12.0",
"@shadcn/react": "^0.3.1",
"chart.js": "^4.5.1",
@@ -36,13 +33,11 @@
"cmdk": "^1.1.1",
"copy-to-clipboard": "^4.0.2",
"d3-force": "^3.0.0",
"date-fns": "^4.4.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"prop-types": "^15.8.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -64,6 +59,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
+1
View File
@@ -5,4 +5,5 @@ module.exports = {
singleQuote: true,
printWidth: 80,
plugins: ['prettier-plugin-tailwindcss'],
tailwindFunctions: ['cn'],
};
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Summarise @shadcn/lint warnings from ESLint JSON output.
*
* npm run lint:design rules × counts, then the 25 worst files
* npm run lint:design -- --rule no-raw-colors files for one rule
* npm run lint:design -- --file src/settings/Tools.tsx every message in a file
*
* Reads ESLint's JSON from stdin; the npm script pipes it in.
*/
import { readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const opt = (name) => {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
};
const onlyRule = opt('--rule');
const onlyFile = opt('--file');
const input = readFileSync(0, 'utf8');
const start = input.indexOf('[');
const results = JSON.parse(input.slice(start));
const rows = [];
for (const file of results) {
const rel = file.filePath.replace(`${process.cwd()}/`, '');
for (const m of file.messages) {
if (!m.ruleId?.startsWith('shadcn/')) continue;
rows.push({
file: rel,
rule: m.ruleId.slice(7),
line: m.line,
msg: m.message,
});
}
}
const count = (key) => {
const c = new Map();
for (const r of rows) c.set(r[key], (c.get(r[key]) ?? 0) + 1);
return [...c.entries()].sort((a, b) => b[1] - a[1]);
};
const pad = (s, n) => String(s).padStart(n);
if (onlyFile) {
const mine = rows.filter(
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
);
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
for (const r of mine.sort((a, b) => a.line - b.line)) {
console.log(
`${pad(r.line, 5)} ${r.rule}\n ${r.msg.split(' See frontend/DESIGN.md')[0]}\n`,
);
}
} else if (onlyRule) {
const mine = rows.filter((r) => r.rule === onlyRule);
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
const byFile = new Map();
for (const r of mine) byFile.set(r.file, (byFile.get(r.file) ?? 0) + 1);
for (const [f, n] of [...byFile.entries()].sort((a, b) => b[1] - a[1]))
console.log(`${pad(n, 5)} ${f}`);
} else {
console.log(
`${rows.length} design warnings in ${new Set(rows.map((r) => r.file)).size} files\n`,
);
console.log('By rule:');
for (const [rule, n] of count('rule')) console.log(`${pad(n, 5)} ${rule}`);
console.log('\nWorst files:');
for (const [file, n] of count('file').slice(0, 25))
console.log(`${pad(n, 5)} ${file}`);
console.log(
'\nNext: npm run lint:design -- --file <path> or -- --rule <rule>',
);
}
+49 -19
View File
@@ -1,20 +1,25 @@
import { envVar } from '@/env';
import './locale/i18n';
import { useState } from 'react';
import { lazy, Suspense, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import Admin from './admin';
import Agents from './agents';
import SharedAgentGate from './agents/SharedAgentGate';
import { selectWorkflowPreviewOpen } from './agents/workflow/workflowPreviewSlice';
import DocsGPTMark from './assets/logo-b.svg';
import DocsGPTMarkWhite from './assets/logo-w.svg';
import ActionButtons from './components/ActionButtons';
import AdminRoute from './components/AdminRoute';
import ErrorBoundary from './components/ErrorBoundary';
import Spinner from './components/Spinner';
import { LoadingState } from '@/components/ui/loading-state';
import { Button } from './components/ui/button';
import { ToastViewport } from './components/ui/toast';
import UploadToast from './components/UploadToast';
import Conversation from './conversation/Conversation';
import { SharedConversation } from './conversation/SharedConversation';
@@ -26,11 +31,18 @@ import Navigation from './Navigation';
import { getSectionForPath } from './navigation/sections';
import { SidebarLevelProvider } from './navigation/SidebarLevelProvider';
import PageNotFound from './PageNotFound';
// Dev-only style guide (see frontend/DESIGN.md). The DEV guard around the
// import lets the bundler drop the chunk from production builds entirely.
const DesignSystem = import.meta.env.DEV
? lazy(() => import('./design/DesignSystem'))
: null;
import Setting from './settings';
import Teams from './settings/Teams';
import Notification from './components/Notification';
import ToolApprovalToast from './notifications/ToolApprovalToast';
import TeamNotificationToast from './notifications/TeamNotificationToast';
import ActionToast from './notifications/ActionToast';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
@@ -58,13 +70,13 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
alt="DocsGPT"
className="h-14 w-auto"
/>
<p className="text-foreground max-w-md px-6 text-center text-sm dark:text-white">
<p className="text-foreground max-w-md px-6 text-center text-sm">
{message}
</p>
<Button
type="button"
onClick={retryOidcLogin}
className="rounded-3xl px-5"
shape="pill"
data-testid="oidc-signin"
>
{t('auth.signInWith', { provider: oidcProviderName || 'SSO' })}
@@ -73,23 +85,23 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
);
}
if (isAuthLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Spinner />
</div>
);
return <LoadingState fill="screen" />;
}
return <EventStreamProvider>{children}</EventStreamProvider>;
}
function MainLayout() {
const { isMobile, isTablet } = useMediaQuery();
const [navOpen, setNavOpen] = useState(!(isMobile || isTablet));
const { isMobile } = useMediaQuery();
const [navOpen, setNavOpen] = useState(!isMobile);
const location = useLocation();
// Settings and admin pages keep the profile menu but drop the chat actions:
// the conversation now survives the trip, so "share" would target a chat
// that isn't on screen.
const inSection = Boolean(getSectionForPath(location.pathname));
// The workflow Preview drawer occupies the right edge; move the toast
// stack to the bottom-left while it's open so it stays visible without
// covering the drawer's attach/send controls.
const previewOpen = useSelector(selectWorkflowPreviewOpen);
return (
<SidebarLevelProvider>
@@ -97,11 +109,10 @@ function MainLayout() {
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
<div
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
: 'ml-0 lg:ml-16'
}`}
className={cn(
'h-[calc(100dvh-64px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-screen',
!isMobile ? (navOpen ? 'lg:ml-72' : 'lg:ml-14') : 'ml-0 lg:ml-16',
)}
>
{/* Contain route render crashes so navigation stays usable;
keyed by path so the boundary resets when the user leaves. */}
@@ -109,9 +120,18 @@ function MainLayout() {
<Outlet />
</ErrorBoundary>
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
{/* The one toast stack (and live region) for the app. Each toast
renders only its cards, top to bottom: team notifications, tool
approvals, uploads, action results. */}
<ToastViewport
className={cn(previewOpen && 'right-auto left-4')}
onMouseDown={(e) => e.stopPropagation()}
>
<TeamNotificationToast />
<ToolApprovalToast />
<UploadToast />
<ActionToast />
</ToastViewport>
</div>
</SidebarLevelProvider>
);
@@ -178,6 +198,16 @@ export default function App() {
</Route>
<Route path="/share/:identifier" element={<SharedConversation />} />
<Route path="/shared/agent/:agentId" element={<SharedAgentGate />} />
{DesignSystem && (
<Route
path="/design"
element={
<Suspense fallback={null}>
<DesignSystem />
</Suspense>
}
/>
)}
<Route path="/*" element={<PageNotFound />} />
</Routes>
</div>
+33 -9
View File
@@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
import modelService from './api/services/modelService';
import DocsGPTLogo from './assets/full-logo-b.svg';
import DocsGPTLogoWhite from './assets/full-logo-w.svg';
import { Button } from './components/ui/button';
import { cn } from './lib/utils';
import {
Select,
SelectContent,
@@ -25,6 +27,7 @@ import {
import type { Model } from './models/types';
function HeroModelSelect() {
const { t } = useTranslation();
const dispatch = useDispatch();
const selectedModel = useSelector(selectSelectedModel);
const availableModels = useSelector(selectAvailableModels);
@@ -93,17 +96,27 @@ function HeroModelSelect() {
disabled={!hasModels}
>
<SelectTrigger
className="bg-muted dark:bg-card text-foreground hover:bg-muted dark:hover:bg-card w-full justify-between rounded-4xl border-0 px-6 py-4 text-base shadow-none data-[state=open]:rounded-b-none"
size="lg"
/* eslint-disable-next-line shadcn/no-restyle --
The landing page's one focal control keeps its own look: a
borderless muted pill at 16px whose menu joins it (DESIGN.md,
Approved exceptions). */
className="bg-muted dark:bg-card text-foreground hover:bg-muted dark:hover:bg-card w-full justify-between rounded-4xl border-0 px-6 py-4 text-base shadow-none data-[state=open]:rounded-b-none md:text-base"
size="field"
>
<SelectValue placeholder="Select Model" />
<SelectValue placeholder={t('conversation.selectModel')} />
</SelectTrigger>
<SelectContent className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0">
<SelectContent
/* eslint-disable-next-line shadcn/no-restyle --
The hero picker's menu hangs from its trigger as one muted
shape (DESIGN.md, Approved exceptions). */
className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0"
>
{hasModels ? (
availableModels?.map((model: Model) => (
<SelectItem
key={model.id}
value={model.id}
/* eslint-disable-next-line shadcn/no-restyle -- hero menu rows match its 16px trigger */
className="px-5 py-3 text-base [&_[data-slot=select-item-indicator]]:right-5"
>
{model.display_name}
@@ -111,7 +124,7 @@ function HeroModelSelect() {
))
) : (
<div className="text-muted-foreground px-5 py-3 text-base">
No models available
{t('agents.form.modelsPopup.noOptionsMessage')}
</div>
)}
</SelectContent>
@@ -161,18 +174,29 @@ export default function Hero({
(demo: { header: string; query: string }, key: number) =>
demo.header &&
demo.query && (
<button
<Button
key={key}
type="button"
variant="outline"
size="lg"
shape="pill"
onClick={() => handleQuestion({ question: demo.query })}
className={`border-border text-foreground hover:bg-muted dark:hover:bg-muted/50 bg-card w-full rounded-full border px-6 py-3.5 text-left transition-colors dark:bg-transparent ${key >= 2 ? 'hidden md:block' : ''}`}
className={cn(
/* eslint-disable-next-line shadcn/no-restyle --
The demo card is a two-line pill (title over a clamped
query at 12px), so it undoes lg's one-line height, the
base's row layout, weight and nowrap. */
'h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal',
key >= 2 && 'hidden md:flex',
)}
>
<p className="text-foreground mb-2 font-semibold">
{demo.header}
</p>
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">
<span className="text-muted-foreground line-clamp-2">
{demo.query}
</span>
</button>
</Button>
),
)}
</div>
+230 -229
View File
@@ -7,9 +7,11 @@ import {
Menu,
PanelLeftClose,
PanelLeftOpen,
Pin,
PinOff,
Plus,
Search as SearchIcon,
Settings as SettingsIcon,
Search,
Settings,
} from 'lucide-react';
import {
@@ -22,12 +24,11 @@ import conversationService from './api/services/conversationService';
import userService from './api/services/userService';
import Discord from './assets/discord.svg';
import Github from './assets/git_nav.svg';
import Pin from './assets/pin.svg';
import { Avatar } from './components/ui/avatar';
import { Button } from './components/ui/button';
import Spinner from './components/Spinner';
import { IconButton } from './components/ui/icon-button';
import { LoadingState } from '@/components/ui/loading-state';
import Twitter from './assets/TwitterX.svg';
import UnPin from './assets/unpin.svg';
import Help from './components/Help';
import {
handleAbort,
@@ -96,7 +97,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const selectedAgent = useSelector(selectSelectedAgent);
const isAdmin = useSelector(selectIsAdmin);
const { isMobile, isTablet } = useMediaQuery();
const { isMobile } = useMediaQuery();
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
// Section state is derived from the route, so deep links and the browser
@@ -143,7 +144,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
: t('navigation.backToApp');
const exitSectionFrom = (section: Section | null) => () => {
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
goToLevel(section?.parentPath ?? lastAppPath.current ?? '/');
};
@@ -153,7 +154,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
: t('navigation.backToApp');
const closeNavOnMobile = () => {
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
};
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
@@ -168,7 +169,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
if (
navRef.current &&
!navRef.current.contains(event.target as Node) &&
(isMobile || isTablet) &&
isMobile &&
navOpen
) {
setNavOpen(false);
@@ -176,13 +177,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}
//event listener only for mobile/tablet when nav is open
if ((isMobile || isTablet) && navOpen) {
if (isMobile && navOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [navOpen, isMobile, isTablet, setNavOpen]);
}, [navOpen, isMobile, setNavOpen]);
useEffect(() => {
function handleSearchShortcut(event: KeyboardEvent) {
@@ -275,7 +276,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const handleAgentClick = (agent: Agent) => {
resetConversation();
dispatch(setSelectedAgent(agent));
if (isMobile || isTablet) setNavOpen(!navOpen);
if (isMobile) setNavOpen(!navOpen);
navigate(agent.id ? agentChatPath(agent.id) : '/c/new');
};
@@ -380,12 +381,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}
useEffect(() => {
setNavOpen(!(isMobile || isTablet));
}, [isMobile, isTablet]);
setNavOpen(!isMobile);
}, [isMobile]);
return (
<>
{(isMobile || isTablet) && navOpen && (
{isMobile && navOpen && (
<div
className="fixed inset-0 z-10 bg-black opacity-50 transition-opacity duration-300"
onClick={() => setNavOpen(false)}
@@ -393,21 +394,20 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
{/* Icon rail (desktop only, when sidebar collapsed) */}
{!navOpen && !isMobile && !isTablet && (
{!navOpen && !isMobile && (
<div
ref={navRef}
className="bg-sidebar border-border scrollbar-overlay fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 overflow-x-hidden overflow-y-auto border-r py-3 lg:flex"
>
<Button
type="button"
variant="ghost"
<IconButton
label={t('navigation.openSidebar')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => setNavOpen(true)}
aria-label="Open navigation menu"
className="text-muted-foreground hover:text-foreground"
>
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
</Button>
<PanelLeftOpen aria-hidden />
</IconButton>
{activeSection ? (
<SectionRail
section={activeSection}
@@ -419,63 +419,59 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
) : (
<>
{queries?.length > 0 && (
<Button
type="button"
variant="ghost"
<IconButton
label={t('newChat')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => newChat()}
aria-label="Start new chat"
className="text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" strokeWidth={1.75} />
</Button>
<Plus aria-hidden />
</IconButton>
)}
<Button
type="button"
variant="ghost"
<IconButton
label={t('manageAgents')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => {
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
aria-label={t('manageAgents')}
className="text-muted-foreground hover:text-foreground"
>
<LayoutGrid className="size-5" strokeWidth={1.75} />
</Button>
<LayoutGrid aria-hidden />
</IconButton>
{conversations?.data && conversations.data.length > 0 && (
<Button
type="button"
variant="ghost"
<IconButton
label={t('modals.searchConversations.searchPlaceholder')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => setSearchOpen(true)}
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
className="text-muted-foreground hover:text-foreground"
>
<SearchIcon className="size-5" strokeWidth={1.75} />
</Button>
<Search aria-hidden />
</IconButton>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<Button
type="button"
variant="ghost"
<IconButton
label={t('settings.label')}
side="right"
variant="ghost-muted"
size="icon"
onClick={() => goToLevel('/settings')}
aria-label={t('settings.label')}
className="text-muted-foreground hover:text-foreground"
>
<SettingsIcon className="size-5" strokeWidth={1.75} />
</Button>
<Settings aria-hidden />
</IconButton>
</div>
</>
)}
</div>
)}
<div
className={`${
!navOpen && '-ml-96 md:-ml-72'
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
className={cn(
'bg-sidebar text-foreground fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-[margin] duration-300 ease-in-out',
!navOpen && '-ml-96 md:-ml-72',
)}
>
<div
className={
@@ -485,34 +481,32 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="min-w-0 flex-1">
<TeamSwitcher
onNavigate={() => {
if (isMobile || isTablet) {
if (isMobile) {
setNavOpen(false);
}
}}
/>
</div>
<Button
type="button"
variant="ghost"
<IconButton
label={
navOpen
? t('navigation.closeSidebar')
: t('navigation.openSidebar')
}
side="bottom"
variant="ghost-muted"
size="icon"
className="text-muted-foreground hover:text-foreground shrink-0"
className="shrink-0"
onClick={() => {
setNavOpen(!navOpen);
}}
aria-label={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{navOpen ? (
<PanelLeftClose
className="size-5 transition-all duration-300 ease-in-out hover:scale-110"
strokeWidth={1.75}
/>
<PanelLeftClose aria-hidden />
) : (
<PanelLeftOpen
className="size-5 transition-all duration-300 ease-in-out hover:scale-110"
strokeWidth={1.75}
/>
<PanelLeftOpen aria-hidden />
)}
</Button>
</IconButton>
</div>
{/* The chat list and the section nav swap places here. Both stay
mounted so the conversation list keeps its scroll position while
@@ -522,21 +516,21 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<NavLink
to={'/c/new'}
onClick={() => {
if (isMobile || isTablet) {
if (isMobile) {
setNavOpen(!navOpen);
}
resetConversation();
}}
className={({ isActive }) =>
`${
isActive ? 'bg-transparent' : ''
} group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white`
cn(
'group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent',
isActive && 'bg-transparent',
)
}
>
<Plus
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Create new chat"
aria-hidden
/>
<p className="text-muted-foreground group-hover:text-foreground text-sm">
{t('newChat')}
@@ -544,16 +538,14 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</NavLink>
<div
id="conversationsMainDiv"
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto dark:text-white"
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
>
{conversations?.loading && !isDeletingConversation && (
<div
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
role="status"
aria-label="Loading conversations"
>
<Spinner size="small" />
</div>
<LoadingState
fill="parent"
size="sm"
className="pointer-events-none absolute inset-0"
/>
)}
{recentAgents?.length > 0 ? (
<div>
@@ -562,112 +554,126 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{t('navigation.agents')}
</p>
</div>
<div className="agents-container">
<div>
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-sidebar-accent'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex items-center gap-2">
<div className="flex w-6 justify-center">
<Avatar
src={agent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
{recentAgents.map((agent, idx) => {
const isCurrent =
agent.id === selectedAgent?.id && !conversationId;
return (
<div key={idx} className="group relative mx-4 mt-4">
<Button
variant="sidebar-item"
asChild
/* eslint-disable-next-line shadcn/no-restyle -- the link and its pin button are siblings, so the row keeps its fill while the pointer is on the pin, and pr-10 keeps the name clear of it */
className="group-hover:bg-sidebar-accent flex w-full pr-10"
>
<Link
to={
agent.id ? agentChatPath(agent.id) : '/c/new'
}
aria-current={isCurrent ? 'page' : undefined}
onClick={(event) => {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey
)
return;
event.preventDefault();
handleAgentClick(agent);
}}
>
<div className="flex w-6 shrink-0 justify-center">
<Avatar
src={agent.image}
alt=""
shape="circle"
className="overflow-hidden"
imgClassName="size-6 object-contain"
/>
</div>
<span className="truncate">{agent.name}</span>
</Link>
</Button>
<div
className={cn(
'absolute top-1 right-1.5 flex items-center',
!isMobile &&
'invisible group-focus-within:visible group-hover:visible',
)}
>
<IconButton
label={
agent.pinned
? t('agents.card.unpin')
: t('agents.card.pin')
}
icon={agent.pinned ? PinOff : Pin}
variant="ghost-on-accent"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name}
</p>
</div>
<div
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
onClick={(e) => {
e.stopPropagation();
handleTogglePin(agent);
}}
aria-label={
agent.pinned ? 'Unpin agent' : 'Pin agent'
}
>
<img
src={agent.pinned ? UnPin : Pin}
className="h-4 w-4"
></img>
</Button>
</div>
</div>
))}
);
})}
</div>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
dispatch(setSelectedAgent(null));
closeNavOnMobile();
goToLevel(AGENTS_MANAGE_ROOT);
}}
className={({ isActive }) =>
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${
isActive ? 'bg-sidebar-accent' : ''
}`
}
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto mt-2 flex"
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
strokeWidth={1.75}
aria-label="manage-agents"
/>
</div>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
dispatch(setSelectedAgent(null));
closeNavOnMobile();
goToLevel(AGENTS_MANAGE_ROOT);
}}
>
<div className="flex w-6 justify-center">
<LayoutGrid
className="text-muted-foreground size-5"
aria-hidden
/>
</div>
<span className="truncate">{t('manageAgents')}</span>
</NavLink>
</Button>
</div>
</div>
) : (
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
closeNavOnMobile();
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
className={({ isActive }) =>
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${
isActive ? 'bg-sidebar-accent' : ''
}`
}
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto mt-2 flex"
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="manage-agents"
/>
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')}
</p>
</NavLink>
<NavLink
to={AGENTS_MANAGE_ROOT}
end
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
event.preventDefault();
closeNavOnMobile();
dispatch(setSelectedAgent(null));
goToLevel(AGENTS_MANAGE_ROOT);
}}
>
<LayoutGrid
className="text-muted-foreground size-5 shrink-0"
aria-hidden
/>
<span className="truncate">{t('manageAgents')}</span>
</NavLink>
</Button>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
@@ -675,25 +681,18 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<p className="mt-1 ml-4 text-sm font-semibold">
{t('chats')}
</p>
<Button
type="button"
variant="ghost"
<IconButton
label={t('modals.searchConversations.searchPlaceholder')}
variant="ghost-muted"
size="icon"
shape="pill"
onClick={() => setSearchOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent mr-1 rounded-full"
aria-label={t(
'modals.searchConversations.searchPlaceholder',
)}
title={t('modals.searchConversations.searchPlaceholder')}
className="mr-1"
>
<SearchIcon
className="size-5"
strokeWidth={1.75}
aria-label="search"
/>
</Button>
<Search aria-hidden />
</IconButton>
</div>
<div className="conversations-container">
<div>
{(conversations.data ?? []).map((conversation) => (
<ConversationTile
key={conversation.id}
@@ -752,38 +751,40 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
</SidebarLevel>
</div>
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end dark:text-white">
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end">
{/* Inside a section its own nav is the way around, so this entry
would only duplicate what is already on screen. Entering settings
no longer clears the conversation either, so the section's back
button can return to it. */}
<div
className={cn(
'dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2',
'flex flex-col gap-2 border-b py-2',
inSection && 'hidden',
)}
>
<Link
to="/settings"
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
closeNavOnMobile();
goToLevel('/settings');
}}
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3"
<Button
variant="sidebar-item"
asChild
className="mx-4 my-auto flex"
>
<SettingsIcon
className="text-muted-foreground size-5 shrink-0"
strokeWidth={1.75}
aria-label="Settings"
/>
<p className="text-foreground text-sm dark:text-white">
{t('settings.label')}
</p>
</Link>
<Link
to="/settings"
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
closeNavOnMobile();
goToLevel('/settings');
}}
>
<Settings
className="text-muted-foreground size-5 shrink-0"
aria-hidden
/>
<p className="text-foreground text-sm">{t('settings.label')}</p>
</Link>
</Button>
</div>
<div className="text-foreground flex flex-col justify-end dark:text-white">
<div className="text-foreground flex flex-col justify-end">
<div className="flex items-center justify-between py-1">
<Help />
@@ -797,7 +798,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
src={Discord}
width={24}
height={24}
alt="Join Discord community"
alt={t('navigation.social.discord')}
className="m-2 w-6 self-center filter dark:invert"
/>
</NavLink>
@@ -810,7 +811,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
src={Twitter}
width={20}
height={20}
alt="Follow us on X"
alt={t('navigation.social.x')}
className="m-2 self-center filter dark:invert"
/>
</NavLink>
@@ -821,7 +822,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<img
src={Github}
alt="View on GitHub"
alt={t('navigation.social.github')}
width={28}
height={28}
className="m-2 self-center filter dark:invert"
@@ -832,18 +833,18 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
</div>
</div>
<div className="dark:border-b-sidebar-border bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
<div className="bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
<div className="relative flex h-full items-center">
<Button
type="button"
variant="ghost"
<IconButton
label={t('navigation.openSidebar')}
side="bottom"
variant="ghost-muted"
size="icon"
className="text-muted-foreground hover:text-foreground ml-4 size-9 lg:hidden"
className="ml-4 lg:hidden"
onClick={() => setNavOpen(true)}
aria-label="Toggle mobile menu"
>
<Menu className="size-5" strokeWidth={1.75} />
</Button>
<Menu aria-hidden />
</IconButton>
</div>
</div>
<ConfirmationModal
@@ -852,7 +853,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
setModalState={(state) => dispatch(setModalStateDeleteConv(state))}
submitLabel={t('modals.deleteConv.delete')}
handleSubmit={handleDeleteAllConversations}
variant="danger"
variant="destructive"
/>
{uploadModalState === 'ACTIVE' && (
<Upload
@@ -874,7 +875,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
token={token}
onSelectConversation={(id) => {
handleConversationClick(id);
if (isMobile || isTablet) setNavOpen(false);
if (isMobile) setNavOpen(false);
}}
/>
)}
+4 -4
View File
@@ -6,16 +6,16 @@ export default function PageNotFound() {
return (
<div className="bg-background grid min-h-screen">
<p className="text-foreground dark:bg-card mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 lg:p-10 xl:p-16">
<h1>{t('pageNotFound.title')}</h1>
<div className="text-foreground bg-muted mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl p-6 lg:p-10 xl:p-16">
<h1 className="text-3xl">{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
<Link
to="/"
className="bg-primary hover:bg-primary/90 mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100"
className="bg-primary hover:bg-primary/90 text-primary-foreground mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 transition-colors duration-100"
>
{t('pageNotFound.goHome')}
</Link>
</p>
</div>
</div>
);
}
+94 -95
View File
@@ -6,8 +6,15 @@ import { useSearchParams } from 'react-router-dom';
import adminService, {
type ActivityFilters,
} from '../api/services/adminService';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { IconButton } from '../components/ui/icon-button';
import SearchInput from '../components/SearchInput';
import {
DescriptionItem,
DescriptionList,
} from '../components/ui/description-list';
import { LoadingState } from '../components/ui/loading-state';
import { MultiSelect } from '../components/ui/multi-select';
import {
Table,
@@ -18,10 +25,11 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { Pagination } from '../components/ui/pagination';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
Pill,
LoadError,
categoryTone,
eventLabel,
eventTone,
@@ -32,6 +40,7 @@ import {
outcomeLabel,
outcomeTone,
} from './AdminUI';
import { cn } from '@/lib/utils';
type ActivityRow = {
feed: string;
@@ -75,10 +84,9 @@ function DetailRow({ label, value }: { label: string; value: unknown }) {
? JSON.stringify(value)
: String(value);
return (
<div className="flex gap-2 text-[13px] break-all">
<span className="text-muted-foreground min-w-32 shrink-0">{label}</span>
<span className="font-mono">{text}</span>
</div>
<DescriptionItem label={label} mono>
{text}
</DescriptionItem>
);
}
@@ -91,24 +99,28 @@ function RowDetail({ row }: { row: ActivityRow }) {
const detail = row.detail ?? {};
const entries = Object.entries(detail);
return (
<div className="bg-muted/40 space-y-1 rounded-lg px-4 py-3">
<DetailRow label="Event" value={row.event} />
<DetailRow label="Journal" value={row.feed} />
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
{row.target_id ? (
<DetailRow label="Affected user" value={row.target_id} />
) : null}
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
{row.user_agent ? (
<DetailRow label="User agent" value={row.user_agent} />
) : null}
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
<div className="bg-muted rounded-lg px-4 py-3">
<DescriptionList layout="columns" size="xs">
<DetailRow label="Event" value={row.event} />
<DetailRow label="Journal" value={row.feed} />
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
{row.target_id ? (
<DetailRow label="Affected user" value={row.target_id} />
) : null}
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
{row.user_agent ? (
<DetailRow label="User agent" value={row.user_agent} />
) : null}
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
</DescriptionList>
{entries.length > 0 ? (
<div className="border-border mt-2 space-y-1 border-t pt-2">
{entries.map(([key, value]) => (
<DetailRow key={key} label={key} value={value} />
))}
<div className="border-border mt-2 border-t pt-2">
<DescriptionList layout="columns" size="xs">
{entries.map(([key, value]) => (
<DetailRow key={key} label={key} value={value} />
))}
</DescriptionList>
</div>
) : null}
</div>
@@ -126,6 +138,7 @@ export default function Activity() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
@@ -183,6 +196,7 @@ export default function Activity() {
const load = useCallback(async () => {
const id = ++requestId.current;
setLoading(true);
setFailed(false);
try {
const res = await adminService.getActivity(
{ ...filters, page, page_size: PAGE_SIZE },
@@ -192,6 +206,9 @@ export default function Activity() {
if (id !== requestId.current) return;
setRows(json.activity ?? []);
setTotal(json.total ?? 0);
setFailed(!res.ok || json.success === false);
} catch {
if (id === requestId.current) setFailed(true);
} finally {
if (id === requestId.current) setLoading(false);
}
@@ -244,16 +261,17 @@ export default function Activity() {
const applySearch = () => withPageReset(setSearch)(searchDraft.trim());
return (
<div className="mt-6">
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Search user, IP or detail"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
onBlur={applySearch}
className="max-w-xs"
/>
<div className="w-full max-w-xs">
<SearchInput
label="Search user, IP or detail"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
onBlur={applySearch}
/>
</div>
<MultiSelect
options={categoryOptions}
selected={categories}
@@ -269,19 +287,21 @@ export default function Activity() {
searchPlaceholder="Find an event"
className="w-56"
/>
<div className="flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(rangeDays)}
onValueChange={(value) =>
value && withPageReset(setRangeDays)(Number(value))
}
aria-label="Range"
>
{RANGES.map((range) => (
<Button
key={range.label}
variant={range.days === rangeDays ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => withPageReset(setRangeDays)(range.days)}
>
<ToggleGroupItem key={range.label} value={String(range.days)}>
{range.label}
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
{hasFilters ? (
<Button
variant="ghost"
@@ -304,7 +324,7 @@ export default function Activity() {
disabled={exporting}
onClick={() => exportAs('csv')}
>
<Download className="size-4" />
<Download />
CSV
</Button>
<Button
@@ -313,14 +333,16 @@ export default function Activity() {
disabled={exporting}
onClick={() => exportAs('ndjson')}
>
<Download className="size-4" />
<Download />
NDJSON
</Button>
</div>
</div>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : failed ? (
<LoadError message="Failed to load activity." onRetry={load} />
) : rows.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No activity.</p>
) : (
@@ -347,51 +369,47 @@ export default function Activity() {
const repeat =
idx > 0 && rows[idx - 1].actor_id === row.actor_id;
return [
<TableRow key={key} className="hover:bg-muted/40">
<TableRow key={key}>
<TableCell>
<button
type="button"
<IconButton
variant="ghost-muted"
size="icon-xs"
aria-expanded={isOpen}
aria-label={
label={
isOpen
? `Hide details for ${eventLabel(row.event)}`
: `Show details for ${eventLabel(row.event)}`
}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring cursor-pointer rounded focus-visible:ring-2 focus-visible:outline-none"
icon={isOpen ? ChevronDown : ChevronRight}
onClick={() => setExpanded(isOpen ? null : key)}
>
{isOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Pill tone={eventTone(row.event)}>
<Badge variant={eventTone(row.event)}>
{eventLabel(row.event)}
</Pill>
</Badge>
{row.outcome ? (
<Pill tone={outcomeTone(row.outcome)}>
<Badge variant={outcomeTone(row.outcome)}>
{outcomeLabel(row.outcome)}
</Pill>
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<Pill tone={categoryTone(row.category)}>
<Badge variant={categoryTone(row.category)}>
{row.category}
</Pill>
</Badge>
</TableCell>
<TableCell
className={`font-mono text-[13px] ${
repeat ? 'text-muted-foreground/50' : ''
}`}
className={cn(
'font-mono text-xs',
repeat ? 'text-muted-foreground/50' : '',
)}
>
{repeat ? '〃' : (row.actor_id ?? '—')}
</TableCell>
<TableCell className="font-mono text-[13px]">
<TableCell className="font-mono text-xs">
{row.target_id && row.target_id !== row.actor_id
? row.target_id
: '—'}
@@ -406,9 +424,7 @@ export default function Activity() {
local
</span>
) : (
<span className="font-mono text-[13px]">
{row.ip}
</span>
<span className="font-mono text-xs">{row.ip}</span>
)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
@@ -419,7 +435,7 @@ export default function Activity() {
</TableRow>,
isOpen ? (
<TableRow key={`${key}:detail`}>
<TableCell colSpan={7} className="py-2">
<TableCell colSpan={7}>
<RowDetail row={row} />
</TableCell>
</TableRow>
@@ -429,29 +445,12 @@ export default function Activity() {
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} events · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<Pagination
page={page}
pageCount={totalPages}
onPageChange={setPage}
summary={`${fmtNumber(total)} events`}
/>
</>
)}
</div>
+15 -11
View File
@@ -23,8 +23,8 @@ describe('eventLabel', () => {
describe('eventTone', () => {
it('flags removals and refusals', () => {
expect(eventTone('oidc_login_denied')).toBe('danger');
expect(eventTone('agent.deleted')).toBe('danger');
expect(eventTone('oidc_login_denied')).toBe('destructive');
expect(eventTone('agent.deleted')).toBe('destructive');
});
it('flags revocations as a warning, not a failure', () => {
@@ -32,18 +32,22 @@ describe('eventTone', () => {
expect(eventTone('agent.key_regenerated')).toBe('warning');
});
it('marks an admin grant with the brand tone', () => {
expect(eventTone('role_granted')).toBe('brand');
it('marks an admin grant with the brand (default) variant', () => {
expect(eventTone('role_granted')).toBe('default');
});
it('leaves routine events muted', () => {
expect(eventTone('oidc_login')).toBe('muted');
expect(eventTone('anything_else')).toBe('muted');
expect(eventTone('oidc_login')).toBe('neutral');
expect(eventTone('anything_else')).toBe('neutral');
});
});
describe('categoryTone', () => {
it('has a tone for every category the API declares', () => {
it('has a variant for every category the API declares', () => {
expect(categoryTone('identity')).toBe('outline');
expect(categoryTone('access')).toBe('default');
expect(categoryTone('safety')).toBe('destructive');
expect(categoryTone('other')).toBe('neutral');
for (const category of [
'identity',
'access',
@@ -58,7 +62,7 @@ describe('categoryTone', () => {
});
it('falls back for an unknown category', () => {
expect(categoryTone('invented')).toBe('muted');
expect(categoryTone('invented')).toBe('neutral');
});
});
@@ -66,11 +70,11 @@ describe('outcomeTone', () => {
// The values below are the only ones the writers produce: guardrails emit
// triggered/not_evaluated, the device feed emits dispatched.
it('flags a guardrail that fired', () => {
expect(outcomeTone('triggered')).toBe('danger');
expect(outcomeTone('triggered')).toBe('destructive');
});
it('leaves a check that never ran neutral', () => {
expect(outcomeTone('not_evaluated')).toBe('muted');
expect(outcomeTone('not_evaluated')).toBe('neutral');
});
it('reads a dispatched device command as a success', () => {
@@ -78,7 +82,7 @@ describe('outcomeTone', () => {
});
it('leaves an unfamiliar outcome muted', () => {
expect(outcomeTone('something-new')).toBe('muted');
expect(outcomeTone('something-new')).toBe('neutral');
});
});
+38 -82
View File
@@ -1,72 +1,39 @@
import Spinner from '../components/Spinner';
import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
import type { VariantProps } from 'class-variance-authority';
export function Loading() {
return (
<div className="flex h-40 items-center justify-center">
<Spinner />
</div>
);
}
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import type { badgeVariants } from '../components/ui/badge';
import {
formatDateOnly,
formatDateTime,
formatRelative,
} from '../utils/dateTimeUtils';
/** A failed admin fetch: a red message with a Retry that re-runs the fetch. */
export function LoadError({
message = 'Failed to load.',
onRetry,
}: {
message?: string;
}) {
return <p className="text-muted-foreground mt-8 text-sm">{message}</p>;
}
export function StatCard({
label,
value,
sub,
}: {
label: string;
value: React.ReactNode;
sub?: string;
onRetry: () => void;
}) {
return (
<div className="border-border dark:border-border rounded-2xl border px-6 py-5">
<p className="text-muted-foreground text-sm">{label}</p>
<p className="text-foreground dark:text-foreground mt-1 text-2xl font-bold tabular-nums">
{value}
</p>
{sub ? <p className="text-muted-foreground mt-1 text-xs">{sub}</p> : null}
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={message}
action={
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
}
/>
);
}
type Tone = 'default' | 'success' | 'danger' | 'muted' | 'brand' | 'warning';
const TONES: Record<Tone, string> = {
default: 'bg-muted text-foreground',
success:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
muted: 'bg-muted text-muted-foreground',
// brand violet — matches the chart's --primary; used for the Admin role.
brand:
'bg-[#7D54D1]/15 text-[#7D54D1] dark:bg-violet-900/40 dark:text-violet-300',
warning:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
export function Pill({
tone = 'default',
children,
}: {
tone?: Tone;
children: React.ReactNode;
}) {
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${TONES[tone]}`}
>
{children}
</span>
);
}
// Audit-feed pills render as Badge; these helpers pick its variant.
type Tone = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
export function fmtDate(value?: string | null): string {
return value ? formatDateTime(value) : '—';
@@ -76,21 +43,10 @@ export function fmtDateShort(value?: string | null): string {
return value ? formatDateOnly(value) : '—';
}
/** "3 minutes ago" in the UI language; `never` when there is no value. */
export function fmtRelative(value?: string | null): string {
if (!value) return 'never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const sec = Math.round((Date.now() - date.getTime()) / 1000);
if (sec < 60) return 'just now';
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
if (day < 30) return `${Math.round(day / 7)}w ago`;
if (day < 365) return `${Math.round(day / 30)}mo ago`;
return `${Math.round(day / 365)}y ago`;
return formatRelative(value) ?? String(value);
}
export function fmtNumber(n?: number | null): string {
@@ -202,25 +158,25 @@ const WARNING_EVENTS = new Set([
]);
export function eventTone(event: string): Tone {
if (DANGER_EVENTS.has(event)) return 'danger';
if (event === 'role_granted') return 'brand';
if (DANGER_EVENTS.has(event)) return 'destructive';
if (event === 'role_granted') return 'default';
if (WARNING_EVENTS.has(event)) return 'warning';
return 'muted';
return 'neutral';
}
// Category facet colors. Mirrors docsgpt/audit_events.py.
const CATEGORY_TONES: Record<string, Tone> = {
identity: 'default',
access: 'brand',
identity: 'outline',
access: 'default',
config: 'warning',
data: 'success',
device: 'warning',
safety: 'danger',
other: 'muted',
safety: 'destructive',
other: 'neutral',
};
export function categoryTone(category: string): Tone {
return CATEGORY_TONES[category] ?? 'muted';
return CATEGORY_TONES[category] ?? 'neutral';
}
// Outcome values the two side journals actually write.
@@ -232,13 +188,13 @@ export function categoryTone(category: string): Tone {
// produced, so every outcome rendered neutral grey -- including a guardrail
// that fired, which is the one signal the merged feed exists to surface.
const OUTCOME_TONES: Record<string, Tone> = {
triggered: 'danger',
not_evaluated: 'muted',
triggered: 'destructive',
not_evaluated: 'neutral',
dispatched: 'success',
};
export function outcomeTone(outcome: string): Tone {
return OUTCOME_TONES[outcome] ?? 'muted';
return OUTCOME_TONES[outcome] ?? 'neutral';
}
const OUTCOME_LABELS: Record<string, string> = {
+25 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import { Badge } from '../components/ui/badge';
import {
Table,
TableBody,
@@ -12,7 +13,8 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import { Loading, Pill, fmtDate } from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { LoadError, fmtDate } from './AdminUI';
type Admin = { user_id: string; granted_at?: string; sources?: string[] };
@@ -20,29 +22,45 @@ export default function Admins() {
const token = useSelector(selectToken);
const [admins, setAdmins] = useState<Admin[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setFailed(false);
adminService
.getAdmins(token)
.then((res) => res.json())
.then((json) => {
if (!cancelled) {
setAdmins(json.admins ?? []);
setFailed(!json.success);
setLoading(false);
}
})
.catch(() => !cancelled && setLoading(false));
.catch(() => {
if (!cancelled) {
setFailed(true);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [token]);
}, [token, reloadKey]);
if (loading) return <Loading />;
if (loading) return <LoadingState fill="block" />;
if (failed)
return (
<LoadError
message="Failed to load admins."
onRetry={() => setReloadKey((k) => k + 1)}
/>
);
return (
<div className="mt-6">
<div>
<p className="text-muted-foreground mb-4 text-sm">
Admin access is the deployment super-admin role. Grant or revoke it from
the Users tab; OIDC-group-derived grants reconcile automatically at
@@ -69,9 +87,9 @@ export default function Admins() {
<TableCell>
<div className="flex flex-wrap gap-1">
{(a.sources ?? []).map((s) => (
<Pill key={s} tone="muted">
<Badge key={s} variant="neutral">
{s}
</Pill>
</Badge>
))}
</div>
</TableCell>
+40
View File
@@ -0,0 +1,40 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { LoadError } from './AdminUI';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('LoadError', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders a destructive empty state whose Retry re-runs the fetch', () => {
const onRetry = vi.fn();
act(() => {
root.render(
<LoadError message="Failed to load users." onRetry={onRetry} />,
);
});
const block = container.querySelector('[data-slot="empty-state"]');
expect(block?.getAttribute('data-tone')).toBe('destructive');
expect(block?.getAttribute('role')).toBe('alert');
expect(block?.textContent).toContain('Failed to load users.');
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Retry');
act(() => retry?.click());
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
+39 -35
View File
@@ -4,7 +4,10 @@ import { Link } from 'react-router-dom';
import adminService from '../api/services/adminService';
import { selectToken } from '../preferences/preferenceSlice';
import { Loading, LoadError, StatCard, fmtNumber } from './AdminUI';
import { Button } from '@/components/ui/button';
import { LoadingState } from '@/components/ui/loading-state';
import StatCard from '@/components/StatCard';
import { LoadError, fmtNumber } from './AdminUI';
function Section({
title,
@@ -14,7 +17,8 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className="mt-8">
// The shell puts the first group 32px under the title.
<div className="mt-8 first:mt-0">
<p className="text-muted-foreground mb-3 text-sm font-medium">{title}</p>
{children}
</div>
@@ -25,6 +29,7 @@ export default function Overview() {
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
@@ -42,10 +47,16 @@ export default function Overview() {
return () => {
cancelled = true;
};
}, [token]);
}, [token, reloadKey]);
if (loading) return <Loading />;
if (!data?.success) return <LoadError message="Failed to load overview." />;
if (loading) return <LoadingState fill="block" />;
if (!data?.success)
return (
<LoadError
message="Failed to load overview."
onRetry={() => setReloadKey((k) => k + 1)}
/>
);
const users = data.users ?? {};
const failed = data.failed_logins_7d ?? 0;
@@ -85,36 +96,29 @@ export default function Overview() {
<Section title="Security & access">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div
className={`rounded-2xl border px-6 py-5 ${
failed > 0
? 'border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30'
: 'border-border'
}`}
>
<p className="text-muted-foreground text-sm">Failed logins (7d)</p>
<p
className={`mt-1 text-2xl font-bold tabular-nums ${
failed > 0
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
}`}
>
{fmtNumber(failed)}
</p>
{failed > 0 ? (
<Link
to="/admin/audit?event=oidc_login_denied"
className="mt-2 inline-block text-xs text-red-600 hover:underline dark:text-red-400"
>
View in Audit →
</Link>
) : (
<p className="text-muted-foreground mt-2 text-xs">
No denied logins
</p>
)}
</div>
<StatCard
label="Failed logins (7d)"
value={fmtNumber(failed)}
tone={failed > 0 ? 'destructive' : 'default'}
valueTone={failed > 0 ? 'destructive' : undefined}
sub={
failed > 0 ? (
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- keeps the tile's destructive tone at hint size
className="text-destructive text-xs font-normal"
>
<Link to="/admin/audit?event=oidc_login_denied">
View in Audit →
</Link>
</Button>
) : (
'No denied logins'
)
}
/>
</div>
</Section>
</div>
+16 -19
View File
@@ -1,9 +1,12 @@
import { CircleAlert } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Progress } from '../components/ui/progress';
import {
Select,
SelectContent,
@@ -45,11 +48,7 @@ export function UsageBar({
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
const percent = usagePercent(budget.used, budget.limit);
const tone =
percent >= 100
? 'bg-red-500'
: percent >= 80
? 'bg-amber-500'
: 'bg-[#7D54D1]';
percent >= 100 ? 'destructive' : percent >= 80 ? 'warning' : 'default';
return (
<div>
<div className="flex items-baseline justify-between gap-3 text-sm">
@@ -60,19 +59,16 @@ export function UsageBar({
</span>
</div>
{budget.limit !== null ? (
<div
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
<Progress
className="mt-1"
size="sm"
variant={tone}
value={percent}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${percent}%` }}
/>
</div>
/>
) : null}
{caption ? (
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
@@ -194,10 +190,10 @@ export default function QuotaEditor({
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">{inheritHint}</p>
{policy && !policy.enabled ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="text-warning text-xs">
This policy is disabled and is not enforced. Saving keeps it disabled.
</p>
) : null}
@@ -231,9 +227,10 @@ export default function QuotaEditor({
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
<Alert variant="destructive">
<CircleAlert className="size-4" aria-hidden="true" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end gap-2">
{policy ? (
+98 -79
View File
@@ -1,10 +1,15 @@
import { TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import {
Select,
SelectContent,
@@ -22,14 +27,8 @@ import {
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import {
LoadError,
Loading,
Pill,
fmtDate,
fmtNumber,
fmtRelative,
} from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { LoadError, fmtDate, fmtNumber, fmtRelative } from './AdminUI';
import QuotaEditor from './QuotaEditor';
import { describeBudget, type QuotaPolicy } from './quotaUtils';
@@ -53,6 +52,10 @@ const HINTS: Record<QuotaScope, string> = {
user: 'Overrides team allowances and the instance default for this user.',
};
// React escapes on render; i18next's own escaping would mangle model ids
// such as `org/model` into `org&#x2F;model`.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
function PolicyCells({ policy }: { policy: QuotaPolicy }) {
return (
<>
@@ -73,6 +76,7 @@ function PolicyCells({ policy }: { policy: QuotaPolicy }) {
}
export default function Quotas() {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [teams, setTeams] = useState<any[]>([]);
@@ -113,18 +117,21 @@ export default function Quotas() {
return teams.filter((team) => !covered.has(String(team.id)));
}, [teams, teamPolicies]);
if (data === null && loading) return <Loading />;
if (!data?.success) return <LoadError message="Failed to load quotas." />;
if (data === null && loading) return <LoadingState fill="block" />;
if (!data?.success)
return <LoadError message="Failed to load quotas." onRetry={load} />;
const bucketPill = (policy: QuotaPolicy) => (
<>
{isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>}
{policy.enabled ? null : <Pill tone="muted">Disabled</Pill>}
{isAll(policy) ? null : (
<Badge variant="neutral">{policy.bucket} traffic</Badge>
)}
{policy.enabled ? null : <Badge variant="neutral">Disabled</Badge>}
</>
);
return (
<div className="mt-6 space-y-8">
<div className="flex flex-col gap-8">
<p className="text-muted-foreground text-sm">
Usage is counted per user over each calendar {data.period} (UTC). The
current window resets {fmtDate(data.resets_at)}. A request is refused
@@ -132,39 +139,49 @@ export default function Quotas() {
</p>
{(data.unpriced_models ?? []).length > 0 ? (
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-foreground font-medium">
Models without a price are invisible to cost limits
</p>
<p className="text-muted-foreground mt-1">
These were used this {data.period} and recorded at $0:{' '}
{(data.unpriced_models as any[])
.map((m) => `${m.model_id} (${fmtNumber(m.tokens)} tokens)`)
.join(', ')}
. Use a token limit for them, or declare their rates in the model
catalog.
</p>
</div>
<Alert variant="warning" role="note">
<TriangleAlert className="size-4" aria-hidden="true" />
<AlertTitle>{t('admin.quotas.unpriced.title')}</AlertTitle>
<AlertDescription>
<p>
{t('admin.quotas.unpriced.body', {
period: data.period,
models: (data.unpriced_models as any[])
.map((m) =>
t('admin.quotas.unpriced.model', {
model: m.model_id,
tokens: fmtNumber(m.tokens),
...NO_ESCAPE,
}),
)
.join(', '),
...NO_ESCAPE,
})}
</p>
</AlertDescription>
</Alert>
) : null}
<section>
<div className="flex items-center justify-between gap-3">
<p className="text-foreground font-bold">Instance default</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'instance',
subjectId: null,
title: 'Instance default',
policy: instancePolicy,
})
}
>
{instancePolicy ? 'Edit' : 'Set default'}
</Button>
</div>
<SectionHeader
title="Instance default"
actions={
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'instance',
subjectId: null,
title: 'Instance default',
policy: instancePolicy,
})
}
>
{instancePolicy ? 'Edit' : 'Set default'}
</Button>
}
/>
<p className="text-muted-foreground mt-1 text-sm">
{instancePolicy
? `${instancePolicy.enabled ? '' : 'Disabled · '}Tokens: ${describeBudget(instancePolicy.token_limit, instancePolicy.token_unlimited, 'tokens')} · Cost: ${describeBudget(instancePolicy.cost_limit_usd, instancePolicy.cost_unlimited, 'cost')}`
@@ -173,41 +190,43 @@ export default function Quotas() {
</section>
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-foreground font-bold">Team allowances</p>
{teamsWithoutPolicy.length > 0 ? (
<div className="flex items-center gap-2">
<Select value={teamPick} onValueChange={setTeamPick}>
<SelectTrigger className="w-52" aria-label="Team">
<SelectValue placeholder="Choose a team" />
</SelectTrigger>
<SelectContent>
{teamsWithoutPolicy.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!teamPick}
onClick={() => {
const team = teams.find((tm) => String(tm.id) === teamPick);
setEditing({
scope: 'team',
subjectId: teamPick,
title: team?.name ?? 'Team',
policy: null,
});
}}
>
Add allowance
</Button>
</div>
) : null}
</div>
<SectionHeader
title="Team allowances"
actions={
teamsWithoutPolicy.length > 0 ? (
<>
<Select value={teamPick} onValueChange={setTeamPick}>
<SelectTrigger className="w-52" aria-label="Team">
<SelectValue placeholder="Choose a team" />
</SelectTrigger>
<SelectContent>
{teamsWithoutPolicy.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!teamPick}
onClick={() => {
const team = teams.find((tm) => String(tm.id) === teamPick);
setEditing({
scope: 'team',
subjectId: teamPick,
title: team?.name ?? 'Team',
policy: null,
});
}}
>
Add allowance
</Button>
</>
) : null
}
/>
{teamPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No team has an allowance.
@@ -266,7 +285,7 @@ export default function Quotas() {
</section>
<section>
<p className="text-foreground font-bold">User overrides</p>
<SectionHeader title="User overrides" />
{userPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No user has an override. Add one from a user&apos;s menu on the
+66 -61
View File
@@ -20,16 +20,14 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { useDarkTheme } from '../hooks';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
LoadError,
StatCard,
fmtMs,
fmtNumber,
fmtUsd,
} from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import StatCard from '@/components/StatCard';
import { Card } from '@/components/ui/card';
import { SectionHeader } from '@/components/ui/section-header';
import { LoadError, fmtMs, fmtNumber, fmtUsd } from './AdminUI';
import { useChartPalette } from '../utils/chartUtils';
import UsageChart from './UsageChart';
import {
GROUP_OPTIONS,
@@ -54,7 +52,8 @@ const RANGES = [7, 30, 90];
export default function Usage() {
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
// Re-read on every theme change; Chart.js can't read CSS variables.
const palette = useChartPalette();
const [days, setDays] = useState(30);
const [groupBy, setGroupBy] = useState<GroupBy>('none');
const [metric, setMetric] = useState<Metric>('tokens');
@@ -70,6 +69,8 @@ export default function Usage() {
token,
);
setData(await res.json().catch(() => ({})));
} catch {
setData({});
} finally {
setLoading(false);
}
@@ -84,33 +85,32 @@ export default function Usage() {
const latency: Latency | undefined = data?.latency;
const chartData = useMemo(
() => buildUsageChart(series, groupBy, metric),
// isDarkTheme re-resolves the canvas colors when the theme toggles.
[series, groupBy, metric, isDarkTheme],
() => buildUsageChart(series, groupBy, metric, palette),
[series, groupBy, metric, palette],
);
const cacheHitRate = useMemo(() => computeCacheHitRate(series), [series]);
if (data === null && loading) return <Loading />;
if (data === null && loading) return <LoadingState fill="block" />;
if (data && !data.success)
return <LoadError message="Failed to load usage." />;
return <LoadError message="Failed to load usage." onRetry={load} />;
return (
<div className="mt-6">
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(days)}
onValueChange={(value) => value && setDays(Number(value))}
aria-label="Range"
>
{RANGES.map((range) => (
<Button
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => setDays(range)}
>
<ToggleGroupItem key={range} value={String(range)}>
{range}d
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
<Select
value={groupBy}
onValueChange={(value) => setGroupBy(value as GroupBy)}
@@ -126,27 +126,19 @@ export default function Usage() {
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Button
variant={metric === 'tokens' ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => setMetric('tokens')}
>
Tokens
</Button>
<Button
variant={metric === 'cost' ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => setMetric('cost')}
>
Cost
</Button>
</div>
<ToggleGroup
type="single"
size="sm"
value={metric}
onValueChange={(value) => value && setMetric(value as Metric)}
aria-label="Metric"
>
<ToggleGroupItem value="tokens">Tokens</ToggleGroupItem>
<ToggleGroupItem value="cost">Cost</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label={`Spend (${days}d)`}
value={fmtUsd(data?.total_cost)}
@@ -176,17 +168,23 @@ export default function Usage() {
/>
</div>
<div className="border-border dark:border-border mt-4 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
<Card
variant="subtle"
padding="lg"
className="mt-4 h-[345px] w-full overflow-hidden"
>
<div className="flex flex-row items-center justify-between gap-3">
<p className="text-foreground dark:text-foreground font-bold">
{metric === 'cost' ? 'Spend' : 'Token usage'}
</p>
<SectionHeader
as="h3"
size="xs"
title={metric === 'cost' ? 'Spend' : 'Token usage'}
/>
<div
id="admin-usage-legend"
className="flex flex-row items-center justify-end"
></div>
</div>
<div className="relative mt-px h-[260px] w-full">
<div className="relative h-[260px] w-full">
{loading ? (
<SkeletonLoader count={1} component={'analysis'} />
) : series.length === 0 ? (
@@ -198,15 +196,19 @@ export default function Usage() {
data={chartData}
legendID="admin-usage-legend"
currency={metric === 'cost'}
gridColor={palette.border}
tickColor={palette.mutedForeground}
/>
)}
</div>
</div>
</Card>
<div className="border-border dark:border-border mt-4 w-full overflow-hidden rounded-2xl border px-6 py-5">
<p className="text-foreground dark:text-foreground mb-3 font-bold">
Top users
</p>
<Card
variant="subtle"
padding="lg"
className="mt-4 w-full overflow-hidden"
>
<SectionHeader as="h3" size="xs" title="Top users" />
{topUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">No usage.</p>
) : (
@@ -221,15 +223,18 @@ export default function Usage() {
</TableHead>
<TableBody>
{topUsers.map((user) => (
<TableRow key={user.user_id} className="hover:bg-muted/40">
<TableCell className="font-mono text-[13px] break-all">
<button
<TableRow key={user.user_id}>
<TableCell className="font-mono text-xs break-all">
<Button
type="button"
className="hover:text-foreground focus-visible:ring-ring cursor-pointer rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
variant="link"
size="inline"
// eslint-disable-next-line shadcn/no-restyle -- the id keeps the cell's 12px mono type and wraps
className="text-left font-mono text-xs font-normal whitespace-normal"
onClick={() => setDrilldown(user.user_id)}
>
{user.user_id}
</button>
</Button>
</TableCell>
<TableCell
align="right"
@@ -249,7 +254,7 @@ export default function Usage() {
</Table>
</TableContainer>
)}
</div>
</Card>
<UserUsageModal userId={drilldown} onClose={() => setDrilldown(null)} />
</div>
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { seriesColor, usageColors } from './UsageChart';
const palette = {
primary: 'c1',
series: ['c1', 'c2', 'c3', 'c4', 'c5'],
success: 's',
warning: 'w',
destructive: 'd',
border: 'b',
mutedForeground: 'm',
};
describe('usageColors', () => {
it('draws prompt in chart-1 and generated in chart-2', () => {
expect(usageColors(palette)).toEqual({ prompt: 'c1', generated: 'c2' });
});
it('reads the theme tokens when no palette is passed', () => {
expect(usageColors()).toEqual({ prompt: '#7d54d1', generated: '#2563eb' });
});
});
describe('seriesColor', () => {
it('walks chart-1 to chart-5 in order', () => {
expect([0, 1, 2, 3, 4].map((i) => seriesColor(i, palette))).toEqual(
palette.series,
);
});
it('never leaves the five chart tokens', () => {
expect(seriesColor(5, palette)).toBe('c1');
});
});
+57 -41
View File
@@ -9,7 +9,12 @@ import {
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { htmlLegendPlugin } from '../utils/chartUtils';
import {
hoverColor,
htmlLegendPlugin,
readChartPalette,
type ChartPalette,
} from '../utils/chartUtils';
import type { ChartData } from 'chart.js';
@@ -22,40 +27,38 @@ ChartJS.register(
Legend,
);
// Chart.js renders to a canvas and can't read CSS variables, so resolve the
// theme color to a concrete string at render time (mirrors settings/Analytics).
function readCssVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
const value = getComputedStyle(document.body).getPropertyValue(name).trim();
return value || fallback;
/**
* Colours for the ungrouped Prompt / Generated pair.
*
* Same as settings/Analytics: prompt is `chart-1` (the brand), generated is
* `chart-2`. Chart.js renders to a canvas, so these are resolved strings.
*
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns The two bar colours.
*/
export function usageColors(palette: ChartPalette = readChartPalette()): {
prompt: string;
generated: string;
} {
return { prompt: palette.series[0], generated: palette.series[1] };
}
export function usageColors(): { prompt: string; generated: string } {
return {
// Violet + pink is the established DocsGPT 2-series pairing (Analytics uses
// --primary + SERIES_COLORS[0]); far more colorblind-separable than blue.
prompt: readCssVar('--primary', '#7D54D1'),
generated: '#FF6384',
};
}
// Follow-on colors for a grouped chart (by model / agent / source), matching
// the palette settings/Analytics already uses for its stacked series.
const SERIES_COLORS = [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40',
'#2BC596',
];
/** Color for dataset ``index``; the first uses the resolved brand color. */
export function seriesColor(index: number): string {
return index === 0
? readCssVar('--primary', '#7D54D1')
: SERIES_COLORS[(index - 1) % SERIES_COLORS.length];
/**
* Colour for dataset `index` of a grouped chart (by model / agent / flow).
*
* Groups have no meaning of their own, so they take `chart-1` to `chart-5` in
* order (DESIGN.md, chart colours). Callers fold anything past five into
* "Other" with `foldSeries`, so the modulo never repeats a colour in practice.
*
* @param index The dataset's position.
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns The bar colour.
*/
export function seriesColor(
index: number,
palette: ChartPalette = readChartPalette(),
): string {
return palette.series[index % palette.series.length];
}
function compactTick(value: number | string): string {
@@ -82,6 +85,10 @@ type UsageChartProps = {
maxTicksLimitInX?: number;
/** Format the y axis as USD rather than a token count. */
currency?: boolean;
/** Grid lines and axis borders (`--border`). */
gridColor: string;
/** Axis tick labels and legend text (`--muted-foreground`). */
tickColor: string;
};
export default function UsageChart({
@@ -89,25 +96,31 @@ export default function UsageChart({
legendID,
maxTicksLimitInX = 8,
currency = false,
gridColor,
tickColor,
}: UsageChartProps) {
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
// The HTML legend copies each item's fontColor, which is this.
legend: { display: false, labels: { color: tickColor } },
htmlLegend: { containerID: legendID },
},
scales: {
x: {
grid: { lineWidth: 0.2, color: '#C4C4C4' },
border: { width: 0.2, color: '#C4C4C4' },
ticks: { maxTicksLimit: maxTicksLimitInX },
grid: { lineWidth: 0.2, color: gridColor },
border: { width: 0.2, color: gridColor },
ticks: { maxTicksLimit: maxTicksLimitInX, color: tickColor },
stacked: true,
},
y: {
grid: { lineWidth: 0.2, color: '#C4C4C4' },
border: { width: 0.2, color: '#C4C4C4' },
ticks: { callback: currency ? currencyTick : compactTick },
grid: { lineWidth: 0.2, color: gridColor },
border: { width: 0.2, color: gridColor },
ticks: {
callback: currency ? currencyTick : compactTick,
color: tickColor,
},
stacked: true,
},
},
@@ -120,7 +133,10 @@ export default function UsageChart({
...data,
datasets: data.datasets.map((dataset) => ({
...dataset,
hoverBackgroundColor: `${dataset.backgroundColor}CC`,
hoverBackgroundColor:
typeof dataset.backgroundColor === 'string'
? hoverColor(dataset.backgroundColor)
: dataset.backgroundColor,
})),
}}
/>
+9 -9
View File
@@ -5,7 +5,9 @@ import adminService from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Modal } from '../components/ui/modal';
import { selectToken } from '../preferences/preferenceSlice';
import { LoadError, Loading, fmtDate } from './AdminUI';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { LoadError, fmtDate } from './AdminUI';
import QuotaEditor, { UsageBar } from './QuotaEditor';
import {
sourceLabel,
@@ -79,13 +81,13 @@ export default function UserQuotaModal({
title={userId ? `Quota · ${userId}` : 'Quota'}
>
{data === null ? (
<Loading />
<LoadingState fill="block" />
) : !data.success ? (
<LoadError message="Failed to load this user's quota." />
<LoadError message="Failed to load this user's quota." onRetry={load} />
) : (
<div className="space-y-5">
<div className="flex flex-col gap-5">
{overall ? (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<UsageBar
label={`Tokens this ${data.period}`}
kind="tokens"
@@ -103,10 +105,8 @@ export default function UserQuotaModal({
</p>
</div>
) : null}
<div className="border-border border-t pt-4">
<p className="text-foreground mb-2 text-sm font-bold">
User override
</p>
<div className="border-border flex flex-col gap-2 border-t pt-4">
<SectionHeader as="h3" size="xs" title="User override" />
<QuotaEditor
scope="user"
subjectId={userId}
+53 -30
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import {
Table,
@@ -13,10 +12,15 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import { useDarkTheme } from '../hooks';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { selectToken } from '../preferences/preferenceSlice';
import { formatDate } from '../utils/dateTimeUtils';
import { Loading, LoadError, StatCard, fmtNumber, fmtUsd } from './AdminUI';
import { Card } from '@/components/ui/card';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import StatCard from '@/components/StatCard';
import { LoadError, fmtNumber, fmtUsd } from './AdminUI';
import { useChartPalette } from '../utils/chartUtils';
import UsageChart, { usageColors } from './UsageChart';
type Bucket = {
@@ -74,7 +78,7 @@ function SplitTable({
<TableBody>
{rows.map((row) => (
<TableRow key={row.key}>
<TableCell className="text-[13px] break-all">
<TableCell className="text-xs break-all">
{labels?.[row.key] ?? row.key}
</TableCell>
<TableCell align="right" className="tabular-nums">
@@ -106,10 +110,12 @@ export default function UserUsageModal({
onClose: () => void;
}) {
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
// Re-read on every theme change; Chart.js can't read CSS variables.
const palette = useChartPalette();
const [days, setDays] = useState(30);
const [data, setData] = useState<any | null>(null);
const [loading, setLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
if (!userId) {
@@ -132,11 +138,11 @@ export default function UserUsageModal({
return () => {
cancelled = true;
};
}, [userId, days, token]);
}, [userId, days, token, reloadKey]);
const series: Bucket[] = data?.series ?? [];
const chartData = useMemo(() => {
const colors = usageColors();
const colors = usageColors(palette);
return {
labels: series.map((bucket) => formatDate(bucket.bucket)),
datasets: [
@@ -152,8 +158,7 @@ export default function UserUsageModal({
},
],
};
// isDarkTheme re-resolves the canvas colors when the theme toggles.
}, [series, isDarkTheme]);
}, [series, palette]);
const totals = data?.totals ?? {};
@@ -164,38 +169,54 @@ export default function UserUsageModal({
title={userId ? `Usage · ${userId}` : 'Usage'}
size="lg"
>
<div className="mb-4 flex items-center gap-1">
<ToggleGroup
type="single"
size="sm"
value={String(days)}
onValueChange={(value) => value && setDays(Number(value))}
aria-label="Range"
className="mb-4"
>
{RANGES.map((range) => (
<Button
key={range}
variant={range === days ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => setDays(range)}
>
<ToggleGroupItem key={range} value={String(range)}>
{range}d
</Button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : data && !data.success ? (
<LoadError message="Failed to load usage." />
<LoadError
message="Failed to load usage."
onRetry={() => setReloadKey((k) => k + 1)}
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<StatCard label="Cost" value={fmtUsd(totals.cost)} />
<StatCard label="Tokens" value={fmtNumber(totals.tokens)} />
<StatCard label="Calls" value={fmtNumber(totals.calls)} />
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-4">
<StatCard
variant="outline"
label="Cost"
value={fmtUsd(totals.cost)}
/>
<StatCard
variant="outline"
label="Tokens"
value={fmtNumber(totals.tokens)}
/>
<StatCard
variant="outline"
label="Calls"
value={fmtNumber(totals.calls)}
/>
</div>
<div className="border-border h-64 rounded-2xl border px-4 py-3">
<Card variant="outline" padding="default" className="h-66">
<div className="flex items-center justify-between">
<p className="text-foreground text-sm font-bold">Daily tokens</p>
<SectionHeader as="h3" size="xs" title="Daily tokens" />
<div id="admin-user-usage-legend" className="flex" />
</div>
<div className="relative mt-px h-48 w-full">
<div className="relative h-48 w-full">
{series.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">
No usage in this period.
@@ -204,10 +225,12 @@ export default function UserUsageModal({
<UsageChart
data={chartData}
legendID="admin-user-usage-legend"
gridColor={palette.border}
tickColor={palette.mutedForeground}
/>
)}
</div>
</div>
</Card>
<SplitTable title="Model" rows={data?.by_model ?? []} />
<SplitTable
+128 -166
View File
@@ -1,4 +1,5 @@
import {
ChevronDown,
Eye,
Gauge,
LogOut,
@@ -8,18 +9,19 @@ import {
UserX,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import ThreeDots from '../assets/three-dots.svg';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import {
ActionMenu,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
type MenuOption,
} from '../components/ui/dropdown-menu';
import { Input } from '../components/ui/input';
import { Modal } from '../components/ui/modal';
import {
Table,
@@ -30,12 +32,19 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import SearchInput from '../components/SearchInput';
import {
DescriptionItem,
DescriptionList,
} from '../components/ui/description-list';
import { LoadingState } from '../components/ui/loading-state';
import { Pagination } from '../components/ui/pagination';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import {
Loading,
Pill,
LoadError,
eventLabel,
fmtDateShort,
fmtNumber,
@@ -62,6 +71,7 @@ type Action = {
const PAGE_SIZE = 25;
export default function Users() {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [users, setUsers] = useState<AdminUser[]>([]);
const [adminIds, setAdminIds] = useState<Set<string>>(new Set());
@@ -70,15 +80,12 @@ export default function Users() {
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [menuUserId, setMenuUserId] = useState<string | null>(null);
const [detail, setDetail] = useState<any | null>(null);
const [usageFor, setUsageFor] = useState<string | null>(null);
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{
ok: boolean;
message: string;
} | null>(null);
const [confirm, setConfirm] = useState<{
message: string;
submitLabel: string;
@@ -88,6 +95,7 @@ export default function Users() {
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const [usersRes, adminsRes] = await Promise.all([
adminService.getUsers(
@@ -103,6 +111,9 @@ export default function Users() {
setAdminIds(
new Set((adminsJson.admins ?? []).map((a: any) => a.user_id)),
);
setFailed(!usersRes.ok || usersJson.success === false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
@@ -112,12 +123,15 @@ export default function Users() {
load();
}, [load]);
// Auto-dismiss the inline feedback banner.
useEffect(() => {
if (!feedback) return;
const id = setTimeout(() => setFeedback(null), 4500);
return () => clearTimeout(id);
}, [feedback]);
// The result shows as a toast in the app's shared ToastViewport
// (ActionToast), which auto-dismisses it.
const setFeedback = (feedback: { ok: boolean; message: string }) =>
dispatch(
showActionToast({
variant: feedback.ok ? 'success' : 'destructive',
message: feedback.message,
}),
);
const run =
(fn: () => Promise<Response>, userId: string, successMsg: string) =>
@@ -248,29 +262,18 @@ export default function Users() {
};
return (
<div className="mt-6">
{feedback ? (
<div
className={`mb-4 rounded-xl border px-4 py-2 text-sm ${
feedback.ok
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-300'
: 'border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300'
}`}
>
{feedback.message}
</div>
) : null}
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by user id"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
className="max-w-xs"
/>
<div className="w-full max-w-xs">
<SearchInput
label="Filter by user id"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
/>
</div>
<Button variant="outline" size="sm" onClick={applySearch}>
Search
</Button>
@@ -290,7 +293,9 @@ export default function Users() {
</div>
{loading ? (
<Loading />
<LoadingState fill="block" />
) : failed ? (
<LoadError message="Failed to load users." onRetry={load} />
) : users.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No users found.</p>
) : (
@@ -313,12 +318,11 @@ export default function Users() {
return (
<TableRow
key={u.user_id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => openDetail(u.user_id)}
>
<TableCell className="max-w-[280px]">
<span
className="block truncate font-mono text-[13px]"
className="block truncate font-mono text-xs"
title={u.user_id}
>
{u.user_id}
@@ -326,9 +330,11 @@ export default function Users() {
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{isAdmin ? <Pill tone="brand">Admin</Pill> : null}
{isAdmin ? (
<Badge variant="default">Admin</Badge>
) : null}
{!u.active ? (
<Pill tone="danger">Inactive</Pill>
<Badge variant="destructive">Inactive</Badge>
) : null}
</div>
</TableCell>
@@ -347,56 +353,31 @@ export default function Users() {
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu
<ActionMenu
open={menuUserId === u.user_id}
onOpenChange={(open) =>
setMenuUserId(open ? u.user_id : null)
}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
className="text-muted-foreground hover:text-foreground h-[35px] w-7"
aria-label="User actions"
>
<img
src={ThreeDots}
alt="User actions"
className="filter dark:invert"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-[176px]"
>
<DropdownMenuItem
onSelect={() => openDetail(u.user_id)}
>
<Eye size={16} />
<span>View details</span>
</DropdownMenuItem>
{buildActions(u.user_id, isAdmin, u.active).map(
(act) => (
<DropdownMenuItem
key={act.key}
variant={
act.destructive
? 'destructive'
: 'default'
}
onSelect={act.perform}
>
<act.icon size={16} />
<span>{act.label}</span>
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
disabled={disabled}
triggerLabel="User actions"
options={[
{
label: 'View details',
icon: Eye,
onClick: () => openDetail(u.user_id),
},
...buildActions(u.user_id, isAdmin, u.active).map(
(act): MenuOption => ({
label: act.label,
icon: act.icon,
variant: act.destructive
? 'destructive'
: 'default',
onClick: act.perform,
}),
),
]}
/>
</div>
</TableCell>
</TableRow>
@@ -405,29 +386,12 @@ export default function Users() {
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} users · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<Pagination
page={page}
pageCount={totalPages}
onPageChange={setPage}
summary={`${fmtNumber(total)} users`}
/>
</>
)}
@@ -437,7 +401,7 @@ export default function Users() {
modalState={confirmState}
setModalState={setConfirmState}
submitLabel={confirm.submitLabel}
variant="danger"
variant="destructive"
handleSubmit={() => {
confirm.run();
setConfirm(null);
@@ -460,80 +424,78 @@ export default function Users() {
size="lg"
footer={
detail ? (
<div className="flex flex-wrap justify-end gap-2">
{buildActions(
detail.user.user_id,
(detail.roles ?? []).includes('admin'),
detail.user?.active ?? true,
).map((act) => (
<Button
key={act.key}
type="button"
variant={act.destructive ? 'destructive-outline' : 'outline'}
size="sm"
onClick={() => {
// Close the detail dialog before any confirm dialog opens
// (avoids stacked modals); the list + banner reflect the result.
setDetail(null);
act.perform();
}}
>
<act.icon size={16} />
{act.label}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="lg" shape="pill">
Actions
<ChevronDown />
</Button>
))}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{buildActions(
detail.user.user_id,
(detail.roles ?? []).includes('admin'),
detail.user?.active ?? true,
).map((act) => (
<DropdownMenuItem
key={act.key}
variant={act.destructive ? 'destructive' : 'default'}
onSelect={() => {
// Close the detail dialog before any confirm dialog opens
// (avoids stacked modals); the list + toast reflect the result.
setDetail(null);
act.perform();
}}
>
<act.icon aria-hidden="true" />
<span>{act.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : undefined
}
>
{detail ? (
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-4 text-sm">
<div>
<p className="text-muted-foreground mb-1 text-xs">
Roles & status
</p>
<div className="flex flex-wrap gap-2">
{(detail.roles ?? []).map((r: string) => (
<Pill key={r} tone={r === 'admin' ? 'brand' : 'muted'}>
<Badge
key={r}
variant={r === 'admin' ? 'default' : 'neutral'}
>
{r}
</Pill>
</Badge>
))}
{detail.user?.active ? (
<Pill tone="success">Active</Pill>
<Badge variant="success">Active</Badge>
) : (
<Pill tone="danger">Inactive</Pill>
<Badge variant="destructive">Inactive</Badge>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Agents</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.agents)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sources</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.sources)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.conversations)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tokens (30d)</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.tokens_30d)}
</span>
</div>
</div>
<DescriptionList layout="justified" size="sm" columns={2}>
<DescriptionItem label="Agents">
{fmtNumber(detail.counts?.agents)}
</DescriptionItem>
<DescriptionItem label="Sources">
{fmtNumber(detail.counts?.sources)}
</DescriptionItem>
<DescriptionItem label="Conversations">
{fmtNumber(detail.counts?.conversations)}
</DescriptionItem>
<DescriptionItem label="Tokens (30d)">
{fmtNumber(detail.counts?.tokens_30d)}
</DescriptionItem>
</DescriptionList>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => {
// Close the detail dialog before the usage dialog opens, so
// the two never stack.
@@ -548,7 +510,7 @@ export default function Users() {
<p className="text-muted-foreground mb-1 text-xs">
Recent auth events
</p>
<div className="max-h-64 space-y-1 overflow-auto">
<div className="flex max-h-64 flex-col gap-1 overflow-auto">
{(detail.recent_events ?? []).length === 0 ? (
<p className="text-muted-foreground">None</p>
) : (
+23 -25
View File
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { useMediaQuery } from '../hooks';
import SectionIndexPage from '../navigation/SectionIndexPage';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionShell from '../navigation/SectionShell';
import { ADMIN_SECTION } from '../navigation/sections';
import Admins from './Admins';
import Activity from './Activity';
@@ -18,32 +18,30 @@ import Users from './Users';
*/
export default function Admin() {
const location = useLocation();
const { isMobile, isTablet } = useMediaQuery();
const { isMobile } = useMediaQuery();
const showIndex =
(isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath;
const showIndex = isMobile && location.pathname === ADMIN_SECTION.rootPath;
if (showIndex) {
return (
<SectionShell width="wide" header={false}>
<SectionIndexPage section={ADMIN_SECTION} />
</SectionShell>
);
}
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-7xl">
{showIndex ? (
<SectionIndexPage section={ADMIN_SECTION} />
) : (
<>
<CurrentSectionHeader />
<Routes>
<Route index element={<Overview />} />
<Route path="overview" element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Activity />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</>
)}
</div>
</div>
<SectionShell width="wide">
<Routes>
<Route index element={<Overview />} />
<Route path="overview" element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Activity />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</SectionShell>
);
}
+28
View File
@@ -68,6 +68,34 @@ describe('buildUsageChart', () => {
expect(chart.datasets[0].label).toBe('unknown');
});
it('folds more than five groups into four plus Other in chart-5', () => {
const keys = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
const chart = buildUsageChart(
keys.map((key, i) =>
// 'a' is the smallest; b..e are the four largest.
bucket({ group_key: key, prompt_tokens: i === 0 ? 1 : 100 - i * 10 }),
),
'model',
'tokens',
);
expect(chart.datasets.map((d) => d.label)).toEqual([
'b',
'c',
'd',
'e',
'Other',
]);
expect(chart.datasets.map((d) => d.backgroundColor)).toEqual([
'color-0',
'color-1',
'color-2',
'color-3',
'color-4',
]);
// Other sums a, f and g (prompt 1, 50, 40, plus 20 generated each).
expect(chart.datasets[4].data).toEqual([1 + 50 + 40 + 60]);
});
it('handles an empty series', () => {
const chart = buildUsageChart([], 'model', 'cost');
expect(chart.labels).toEqual([]);
+30 -11
View File
@@ -1,3 +1,5 @@
import { foldSeries, OTHER_SERIES_KEY } from '../settings/foldSeries';
import type { ChartPalette } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { seriesColor, usageColors } from './UsageChart';
@@ -34,12 +36,20 @@ function valueOf(row: UsageBucket, metric: Metric): number {
* Ungrouped gives one dataset per token kind (or a single cost series);
* grouped gives one dataset per group key, each aligned to the shared bucket
* axis so a group missing a day plots 0 rather than shifting the whole series
* left by one bar.
* left by one bar. Past five groups, the four largest stay and the rest sum
* into one "Other" series (`foldSeries`), so no chart colour repeats.
*
* @param series Usage rows from the server.
* @param groupBy How the rows are grouped.
* @param metric Tokens or cost.
* @param palette The resolved theme palette; read from the DOM when omitted.
* @returns Chart.js bar data.
*/
export function buildUsageChart(
series: UsageBucket[],
groupBy: GroupBy,
metric: Metric,
palette?: ChartPalette,
) {
const buckets = Array.from(new Set(series.map((row) => row.bucket))).sort();
const labels = buckets.map((bucket) => formatDate(bucket));
@@ -52,12 +62,12 @@ export function buildUsageChart(
{
label: 'Cost',
data: series.map((row) => row.cost),
backgroundColor: seriesColor(0),
backgroundColor: seriesColor(0, palette),
},
],
};
}
const colors = usageColors();
const colors = usageColors(palette);
return {
labels,
datasets: [
@@ -78,20 +88,29 @@ export function buildUsageChart(
const keys = Array.from(
new Set(series.map((row) => row.group_key ?? 'unknown')),
);
return {
labels,
datasets: keys.map((key, index) => {
// Each group aligned to the shared bucket axis, then folded past five.
const entries = foldSeries(
keys.map((key): [string, Record<string, number>] => {
const byBucket = new Map(
series
.filter((row) => (row.group_key ?? 'unknown') === key)
.map((row) => [row.bucket, valueOf(row, metric)]),
);
return {
label: key,
data: buckets.map((bucket) => byBucket.get(bucket) ?? 0),
backgroundColor: seriesColor(index),
};
return [
key,
Object.fromEntries(
buckets.map((bucket) => [bucket, byBucket.get(bucket) ?? 0]),
),
];
}),
);
return {
labels,
datasets: entries.map(([key, values], index) => ({
label: key === OTHER_SERIES_KEY ? 'Other' : key,
data: buckets.map((bucket) => values[bucket] ?? 0),
backgroundColor: seriesColor(index, palette),
})),
};
}
+79 -223
View File
@@ -1,44 +1,32 @@
import { SyntheticEvent, useState } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import {
Activity,
Copy,
Download,
ExternalLink,
Folder,
Pencil,
Pin,
PinOff,
Trash2,
Users,
} from 'lucide-react';
import userService from '../api/services/userService';
import Download from '../assets/download.svg';
import Duplicate from '../assets/duplicate.svg';
import Edit from '../assets/edit.svg';
import FolderIcon from '../assets/folder.svg';
import Link from '../assets/link-gray.svg';
import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg';
import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import { Modal } from '../components/ui/modal';
import ConfirmationModal from '../modals/ConfirmationModal';
import MoveToFolderModal from '../modals/MoveToFolderModal';
import { ActiveState } from '../models/misc';
import { useSidebarLevel } from '../navigation/SidebarLevelProvider';
import ShareToTeamModal from '../teams/ShareToTeamModal';
type AgentMenuOption = {
icon: string | LucideIcon;
label: string;
onClick: (event: SyntheticEvent) => void;
variant: 'default' | 'destructive';
iconWidth?: number;
iconHeight?: number;
};
import {
selectAgents,
selectToken,
@@ -82,57 +70,43 @@ export default function AgentCard({
const [shareModalOpen, setShareModalOpen] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const menuOptionsConfig: Record<string, AgentMenuOption[]> = {
const openEditor = () => {
if (agent.agent_type === 'workflow') {
goToLevel(agentEditPath(agent.id, true));
} else {
goToLevel(agentEditPath(agent.id));
}
};
const pinOption: MenuOption = {
icon: agent.pinned ? PinOff : Pin,
label: agent.pinned ? t('agents.card.unpin') : t('agents.card.pin'),
onClick: () => togglePin(),
};
const menuOptionsConfig: Record<string, MenuOption[]> = {
template: [
{
icon: Duplicate,
icon: Copy,
label: t('modals.prompts.duplicate'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleDuplicate();
},
variant: 'default',
iconWidth: 18,
iconHeight: 18,
onClick: () => handleDuplicate(),
},
],
user: [
{
icon: Monitoring,
icon: Activity,
label: t('agents.form.buttons.logs'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
goToLevel(agentLogsPath(agent.id));
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
onClick: () => goToLevel(agentLogsPath(agent.id)),
},
{
icon: Edit,
icon: Pencil,
label: t('agents.edit'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
goToLevel(agentEditPath(agent.id, true));
} else {
goToLevel(agentEditPath(agent.id));
}
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
onClick: openEditor,
},
{
icon: Download,
label: t('agents.exportAgent'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleExport();
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
onClick: () => handleExport(),
},
// Sharing is an owner-only action: only show it for agents the user
// owns ('user'), not agents shared into their workspace by a team.
@@ -141,54 +115,21 @@ export default function AgentCard({
{
icon: Users,
label: t('agents.shareWithTeam'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setShareModalOpen(true);
},
variant: 'default' as const,
iconWidth: 14,
iconHeight: 14,
},
]
: []),
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned
? t('agents.card.unpin')
: t('agents.card.pin'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'default' as const,
iconWidth: 18,
iconHeight: 18,
onClick: () => setShareModalOpen(true),
},
]
: []),
...(agent.status === 'published' ? [pinOption] : []),
{
icon: FolderIcon,
icon: Folder,
label: t('agents.folders.moveToFolder'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setMoveModalState('ACTIVE');
},
variant: 'default',
iconWidth: 16,
iconHeight: 15,
onClick: () => setMoveModalState('ACTIVE'),
},
{
icon: Trash,
icon: Trash2,
label: t('agents.form.buttons.delete'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
onClick: () => setDeleteConfirmation('ACTIVE'),
variant: 'destructive',
iconWidth: 13,
iconHeight: 13,
},
],
// Agents shared with the user via a team. They don't own it, so only
@@ -197,71 +138,24 @@ export default function AgentCard({
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
team: [
{
icon: Edit,
icon: Pencil,
label: t('agents.edit'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
goToLevel(agentEditPath(agent.id, true));
} else {
goToLevel(agentEditPath(agent.id));
}
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
onClick: openEditor,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned
? t('agents.card.unpin')
: t('agents.card.pin'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'default' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
...(agent.status === 'published' ? [pinOption] : []),
],
shared: [
{
icon: Link,
icon: ExternalLink,
label: t('agents.card.open'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(sharedAgentPath(agent.shared_token));
},
variant: 'default',
iconWidth: 12,
iconHeight: 12,
onClick: () => navigate(sharedAgentPath(agent.shared_token)),
},
pinOption,
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? t('agents.card.unpin') : t('agents.card.pin'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'default',
iconWidth: 18,
iconHeight: 18,
},
{
icon: Trash,
icon: Trash2,
label: t('agents.card.remove'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleHideSharedAgent();
},
onClick: () => handleHideSharedAgent(),
variant: 'destructive',
iconWidth: 13,
iconHeight: 13,
},
],
};
@@ -384,11 +278,14 @@ export default function AgentCard({
updateAgents?.(updatedAgents);
};
return (
<div
<Card
variant="filled"
interactive={agent.status === 'published'}
padding="lg"
role={agent.status === 'published' ? 'button' : undefined}
tabIndex={agent.status === 'published' ? 0 : undefined}
aria-label={agent.status === 'published' ? agent.name : undefined}
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-[3px] sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
className="relative h-44 justify-between"
onClick={(e) => {
e.stopPropagation();
handleClick();
@@ -403,69 +300,30 @@ export default function AgentCard({
}
}}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="absolute top-4 right-4 z-10 cursor-pointer"
aria-label="agent-actions"
>
<img
src={ThreeDots}
alt={'use-agent'}
className="h-[19px] w-[19px]"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[144px]">
{menuOptions.map((option, index) => (
<DropdownMenuItem
key={index}
variant={option.variant}
onClick={(e) => e.stopPropagation()}
onSelect={(event) => {
option.onClick(event as unknown as SyntheticEvent);
}}
>
{typeof option.icon === 'string' ? (
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
) : (
<option.icon
size={Math.max(
option.iconWidth ?? 16,
option.iconHeight ?? 16,
)}
strokeWidth={1.75}
aria-hidden="true"
/>
)}
<span>{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ActionMenu
options={menuOptions}
triggerLabel={t('agents.card.actions')}
align="end"
className="absolute top-3 right-3 z-10"
/>
{/* Team access badge — pinned to the top row, left of the ⋯ menu
(right-11 clears the 19px trigger at right-4) so the two align. */}
(right-11 clears the 28px trigger at right-3) so the two align. */}
{agent.ownership === 'team' && (
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<Users size={11} strokeWidth={2} aria-hidden="true" />
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
<Users aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
</span>
</Badge>
)}
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<Avatar
src={agent.image}
alt={`${agent.name}`}
imgClassName="h-7 w-7 rounded-full object-contain"
size="xs"
shape="circle"
imgClassName="size-7 object-contain"
/>
{agent.status === 'draft' && (
<p className="text-foreground text-xs opacity-50">
@@ -473,16 +331,13 @@ export default function AgentCard({
</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="text-foreground truncate px-1 text-sm leading-relaxed font-semibold capitalize"
>
<div className="mt-2 px-1">
<CardTitle title={agent.name} className="truncate capitalize">
{agent.name}
</p>
<p className="dark:text-muted-foreground text-muted-foreground mt-1 h-20 overflow-auto px-1 text-xs leading-relaxed">
</CardTitle>
<CardDescription size="xs" className="mt-1 line-clamp-3">
{agent.description}
</p>
</CardDescription>
</div>
</div>
<ConfirmationModal
@@ -495,7 +350,7 @@ export default function AgentCard({
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('cancel')}
variant="danger"
variant="destructive"
/>
<Modal
open={exportError !== null}
@@ -508,7 +363,8 @@ export default function AgentCard({
<Button
type="button"
onClick={() => setExportError(null)}
className="rounded-3xl px-5"
size="lg"
shape="pill"
>
{t('agents.close')}
</Button>
@@ -532,6 +388,6 @@ export default function AgentCard({
onClose={() => setShareModalOpen(false)}
/>
)}
</div>
</Card>
);
}
+26 -27
View File
@@ -8,8 +8,7 @@ import { selectToken } from '../preferences/preferenceSlice';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
import { formatDateTime } from '../utils/dateTimeUtils';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionPills from '../navigation/SectionPills';
import SectionShell from '../navigation/SectionShell';
import GuardrailEvents from './components/GuardrailEvents';
import { Agent } from './types';
@@ -40,35 +39,35 @@ export default function AgentLogs() {
}, [agentId, token]);
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{agentId && (
<>
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{agentId && (
<>
<div className="mt-8">
<Analytics agentId={agentId} />
<GuardrailEvents agentId={agentId} />
</div>
<GuardrailEvents agentId={agentId} />
<div className="mt-8">
<Logs
agentId={agentId}
tableHeader={t('agents.logs.tableHeader')}
/>
</>
)}
</div>
</div>
</div>
</>
)}
</SectionShell>
);
}
@@ -0,0 +1,56 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import AgentPageHeader from './AgentPageHeader';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentPageHeader sub-nav', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders the tabs as underline tab buttons, marking the current page', () => {
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentId="a1"
agentName="Renewals"
currentPage="logs"
/>
</MemoryRouter>,
);
});
const nav = container.querySelector(
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
);
const tabs = Array.from(nav?.children ?? []);
expect(tabs).toHaveLength(3);
for (const tab of tabs) {
expect(tab.getAttribute('data-variant')).toBe('tab');
expect(tab.getAttribute('data-size')).toBe('inline');
}
const current = tabs[1];
expect(current.tagName).toBe('SPAN');
expect(current.getAttribute('aria-current')).toBe('page');
expect(current.getAttribute('data-active')).toBe('true');
expect(tabs[0].tagName).toBe('A');
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
});
});
+18 -21
View File
@@ -10,6 +10,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
@@ -100,7 +101,7 @@ export default function AgentPageHeader({
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch] truncate">
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
@@ -133,35 +134,31 @@ export default function AgentPageHeader({
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// Always render a 2px bottom border so row height stays constant
// between active/inactive; only the color changes.
const baseClasses =
'whitespace-nowrap border-b-2 pb-1 text-sm font-medium transition-colors';
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
return (
<span
<Button
key={tab.id}
aria-current="page"
className={cn(
baseClasses,
'border-primary text-foreground -mb-px',
)}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
{tab.label}
</span>
<span aria-current="page">{tab.label}</span>
</Button>
);
}
return (
<Link
<Button
key={tab.id}
to={tab.href}
className={cn(
baseClasses,
'text-muted-foreground hover:text-foreground hover:border-border/60 -mb-px border-transparent',
)}
asChild
variant="tab"
size="inline"
className="-mb-px"
>
{tab.label}
</Link>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
})}
</nav>
+1 -1
View File
@@ -120,7 +120,7 @@ export default function AgentPreview() {
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-sm font-medium">
Test your agent
{t('agents.preview.emptyTitle')}
</p>
</section>
) : (
+69 -72
View File
@@ -1,4 +1,3 @@
import { Search } from 'lucide-react';
import {
Fragment,
useCallback,
@@ -39,7 +38,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '../components/ui/breadcrumb';
import { CurrentSectionHeader } from '../navigation/SectionPageHeader';
import SectionShell from '../navigation/SectionShell';
import PageToolbar from '../components/PageToolbar';
import SearchInput from '../components/SearchInput';
import SectionPills from '../navigation/SectionPills';
import { useAgentSearch } from './hooks/useAgentSearch';
import { agentsListPath, filterFromPath } from './paths';
@@ -173,74 +174,62 @@ export default function AgentsList() {
activeFilter === 'all';
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<p className="text-muted-foreground mt-5 text-sm leading-6">
{t('agents.description')}
</p>
<SectionShell>
<p className="text-muted-foreground text-sm leading-6">
{t('agents.description')}
</p>
<SectionPills className="mt-6" />
<SectionPills className="mt-6" />
<div className="mt-6 flex flex-col gap-4 pb-4">
<div className="w-full max-w-md">
<Input
type="text"
<div className="mt-6">
<PageToolbar
search={
<SearchInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
label={t('agents.searchPlaceholder')}
labelBgClassName="bg-background"
className="rounded-full"
leftIcon={
<Search
className="text-muted-foreground size-4"
strokeWidth={1.75}
/>
}
/>
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={
totalAgentsBySection[sectionConfig.id as AgentSectionId]
}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
))}
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
}
/>
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={totalAgentsBySection[sectionConfig.id as AgentSectionId]}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
))}
{showSearchEmptyState && (
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
/>
</SectionShell>
);
}
@@ -432,7 +421,7 @@ function AgentSection({
{config.showNewAgentButton && (
<Button
type="button"
className="rounded-full text-white"
shape="pill"
onClick={() => {
setModalFolderId(null);
setShowAgentTypeModal(true);
@@ -453,6 +442,9 @@ function AgentSection({
// Drilling into a folder is a trail, not a back button — the
// sidebar's back is the only thing that means "leave".
<Breadcrumb>
{/* eslint-disable-next-line shadcn/no-restyle -- the folder
trail stands in for the section <h2>, so it keeps heading
typography (DESIGN.md Approved exceptions). */}
<BreadcrumbList className="text-foreground gap-2 text-lg font-semibold sm:gap-2">
<BreadcrumbItem>
<BreadcrumbLink asChild>
@@ -469,7 +461,9 @@ function AgentSection({
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === breadcrumbItems.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
<BreadcrumbPage className="max-w-[32ch]">
{item.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<button
@@ -525,7 +519,8 @@ function AgentSection({
<Button
type="button"
variant="outline"
className="bg-card shrink-0 rounded-full whitespace-nowrap"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => {
setIsCreatingFolder(true);
setTimeout(() => newFolderInputRef.current?.focus(), 0);
@@ -538,7 +533,8 @@ function AgentSection({
<Button
type="button"
variant="outline"
className="bg-card shrink-0 rounded-full whitespace-nowrap"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => setShowImportModal(true)}
>
{t('agents.importAgent')}
@@ -547,7 +543,8 @@ function AgentSection({
{config.showNewAgentButton && (
<Button
type="button"
className="shrink-0 rounded-full whitespace-nowrap text-white"
shape="pill"
className="shrink-0 whitespace-nowrap"
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
@@ -565,14 +562,14 @@ function AgentSection({
<div className="flex flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SkeletonLoader component="agentCards" count={4} />
</div>
) : (
<>
{/* Show subfolders at current level */}
{config.id === 'user' && currentLevelFolders.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{currentLevelFolders.map((folder) => (
<FolderCard
key={folder.id}
@@ -580,7 +577,6 @@ function AgentSection({
agentCount={getAgentsForFolder(folder.id).length}
onDelete={onDeleteFolder}
onRename={onRenameFolder}
isExpanded={false}
onToggleExpand={handleNavigateIntoFolder}
/>
))}
@@ -589,7 +585,7 @@ function AgentSection({
{/* Show agents at current level */}
{unfolderedAgents.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{unfolderedAgents.map((agent) => (
<AgentCard
key={agent.id}
@@ -610,7 +606,8 @@ function AgentSection({
{config.showNewAgentButton && !currentFolderId && (
<Button
type="button"
className="ml-2 rounded-full text-white"
shape="pill"
className="ml-2"
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
+27 -83
View File
@@ -1,35 +1,20 @@
import { SyntheticEvent, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pencil, Trash2 } from 'lucide-react';
import Edit from '../assets/edit.svg';
import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Card, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import ConfirmationModal from '../modals/ConfirmationModal';
import FolderNameModal from '../modals/FolderManagementModal';
import { ActiveState } from '../models/misc';
import { AgentFolder } from './types';
type FolderMenuOption = {
icon: string;
label: string;
onClick: (event: SyntheticEvent) => void;
variant: 'default' | 'destructive';
iconWidth?: number;
iconHeight?: number;
};
import { AgentFolder } from './types';
type FolderCardProps = {
folder: AgentFolder;
agentCount: number;
onDelete: (folderId: string) => Promise<boolean>;
onRename: (folderId: string, newName: string) => void;
isExpanded: boolean;
onToggleExpand: (folderId: string) => void;
};
@@ -38,7 +23,6 @@ export default function FolderCard({
agentCount,
onDelete,
onRename,
isExpanded,
onToggleExpand,
}: FolderCardProps) {
const { t } = useTranslation();
@@ -47,28 +31,17 @@ export default function FolderCard({
const [renameModalState, setRenameModalState] =
useState<ActiveState>('INACTIVE');
const menuOptions: FolderMenuOption[] = [
const menuOptions: MenuOption[] = [
{
icon: Edit,
icon: Pencil,
label: t('agents.folders.rename'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setRenameModalState('ACTIVE');
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
onClick: () => setRenameModalState('ACTIVE'),
},
{
icon: Trash,
icon: Trash2,
label: t('agents.folders.delete'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
onClick: () => setDeleteConfirmation('ACTIVE'),
variant: 'destructive',
iconWidth: 13,
iconHeight: 13,
},
];
@@ -78,13 +51,13 @@ export default function FolderCard({
return (
<>
<div
<Card
variant="filled"
interactive
padding="default"
role="button"
tabIndex={0}
aria-pressed={isExpanded}
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-[3px] sm:w-48 ${
isExpanded ? 'bg-accent' : 'bg-muted hover:bg-accent'
}`}
className="relative flex-row items-center justify-between"
onClick={() => onToggleExpand(folder.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -94,50 +67,21 @@ export default function FolderCard({
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<span className="text-foreground truncate text-sm font-medium">
{folder.name}
</span>
<CardTitle className="truncate">{folder.name}</CardTitle>
<span className="text-muted-foreground shrink-0 text-xs">
({agentCount})
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="ml-2 shrink-0 cursor-pointer"
aria-label={t('agents.folders.menuAriaLabel', {
folderName: folder.name,
defaultValue: 'Folder actions',
})}
>
<img src={ThreeDots} alt="" className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[144px]">
{menuOptions.map((option, index) => (
<DropdownMenuItem
key={index}
variant={option.variant}
onClick={(e) => e.stopPropagation()}
onSelect={(event) => {
option.onClick(event as unknown as SyntheticEvent);
}}
>
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
<span>{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<ActionMenu
options={menuOptions}
triggerLabel={t('agents.folders.menuAriaLabel', {
folderName: folder.name,
interpolation: { escapeValue: false },
})}
align="end"
className="ml-2 shrink-0"
/>
</Card>
<ConfirmationModal
message={t('agents.folders.deleteConfirm')}
modalState={deleteConfirmation}
@@ -148,7 +92,7 @@ export default function FolderCard({
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('cancel')}
variant="danger"
variant="destructive"
/>
<FolderNameModal
modalState={renameModalState}
+338
View File
@@ -0,0 +1,338 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mockState = {
preference: {
token: null,
sourceDocs: [],
selectedAgent: null,
prompts: [],
agentFolders: [],
},
};
vi.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
useDispatch: () => vi.fn(),
}));
const jsonResponse = (body: unknown, ok = true) =>
Promise.resolve({ ok, json: () => Promise.resolve(body) });
vi.mock('../api/services/userService', () => ({
default: {
getUserTools: () =>
jsonResponse({
tools: [
{
id: 'tool-1',
name: 'remote_device',
display_name: 'Laptop',
config: { device_id: 'device-1' },
},
],
}),
getAgentFolders: () => jsonResponse({ folders: [] }),
getAgent: () => jsonResponse({}),
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
updateAgent: () => jsonResponse({}),
deleteAgent: () => jsonResponse({}),
createPrompt: () => jsonResponse({}),
},
}));
vi.mock('../api/services/devicesService', () => ({
default: {
list: () =>
Promise.resolve({
devices: [{ id: 'device-1', last_seen_at: new Date().toISOString() }],
}),
},
}));
vi.mock('../api/services/modelService', () => ({
default: {
getModels: () => jsonResponse({ models: [] }),
transformModels: () => [],
},
}));
// The pickers render only their trigger here, plus each item's rich
// description so the device status pill can be checked.
vi.mock('../components/MultiSelectPopover', () => ({
MultiSelectPopover: ({
trigger,
items,
}: {
trigger: React.ReactNode;
items: MultiSelectPopoverItem[];
}) => (
<div data-testid="picker">
{trigger}
{items.map((item) => (
<div key={item.id}>{item.descriptionNode}</div>
))}
</div>
),
}));
vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
vi.mock('./AgentPreview', () => ({ default: () => null }));
vi.mock('../settings/Prompts', () => ({ default: () => null }));
vi.mock('./components/GuardrailsSection', () => ({
default: () => null,
guardrailsIncomplete: () => false,
}));
vi.mock('../upload/Upload', () => ({ default: () => null }));
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
vi.mock('../navigation/SectionPageHeader', () => ({
CurrentSectionHeader: () => null,
}));
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
vi.mock('../components/ToolIcon', () => ({ default: () => null }));
import NewAgent from './NewAgent';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const setNativeValue = (
el: HTMLInputElement | HTMLTextAreaElement,
value: string,
) => {
const proto =
el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
describe('NewAgent form', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<MemoryRouter>
<NewAgent mode="new" />
</MemoryRouter>,
);
});
};
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(text),
)!;
// Decision 63 (3): every field, picker and button in the form is 42px.
it('renders the name field as a default-size (42px) pill Input', async () => {
await render();
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
expect(name.getAttribute('data-slot')).toBe('input');
expect(name.getAttribute('data-size')).toBe('default');
expect(name.getAttribute('data-shape')).toBe('pill');
});
it('renders the Add prompt button at the field height', async () => {
await render();
const add = buttonByText('agents.form.buttons.add');
expect(add.getAttribute('data-variant')).toBe('outline-primary');
expect(add.getAttribute('data-size')).toBe('field');
expect(add.getAttribute('data-shape')).toBe('pill');
});
it('renders the token and request limits as default-size pill Inputs', async () => {
await render();
await act(async () =>
buttonByText('agents.form.sections.advanced').click(),
);
for (const key of ['enterTokenLimit', 'enterRequestLimit']) {
const field = container.querySelector(
`input[placeholder="agents.form.placeholders.${key}"]`,
)!;
expect(field.getAttribute('data-size')).toBe('default');
expect(field.getAttribute('data-shape')).toBe('pill');
}
});
it('titles each form panel with a SectionHeader spaced by the panel gap', async () => {
await render();
const titles = Array.from(
container.querySelectorAll('[data-slot="section-header"] > h2'),
).map((h) => h.textContent);
expect(titles).toEqual(
expect.arrayContaining([
'agents.form.sections.meta',
'agents.form.sections.source',
'agents.form.sections.tools',
'agents.form.sections.agentType',
'agents.form.sections.models',
]),
);
const meta = Array.from(
container.querySelectorAll('[data-slot="section-header"]'),
).find((h) => h.textContent === 'agents.form.sections.meta')!;
expect(meta.parentElement!.className.split(' ')).toEqual(
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
);
expect((meta.nextElementSibling as HTMLElement).className).not.toContain(
'mt-5',
);
});
// Decision 64: the Advanced header is a section-toggle with a leading
// lucide chevron; the panel draws the focus ring.
it('renders the Advanced header as a section-toggle', async () => {
await render();
const toggle = buttonByText('agents.form.sections.advanced');
expect(toggle.getAttribute('data-variant')).toBe('section-toggle');
expect(toggle.getAttribute('data-size')).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
const chevron = toggle.firstElementChild!;
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
expect(toggle.querySelector('h2')).toBeNull();
expect(toggle.parentElement!.tagName).toBe('H2');
const panel = toggle.closest('.bg-card')!;
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(chevron.getAttribute('class')).toContain('rotate-90');
});
it('renders the description as a large ui Textarea', async () => {
await render();
const description = container.querySelector(
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
)!;
expect(description.getAttribute('data-slot')).toBe('textarea');
expect(description.getAttribute('data-size')).toBe('lg');
});
it('renders the pickers as pill comboboxes, muted while empty', async () => {
await render();
const triggers = Array.from(
container.querySelectorAll('[data-testid="picker"] > button'),
);
// Sources, tools, models.
expect(triggers).toHaveLength(3);
for (const trigger of triggers) {
expect(trigger.getAttribute('data-variant')).toBe('combobox');
expect(trigger.getAttribute('data-size')).toBe('field');
expect(trigger.getAttribute('data-shape')).toBe('pill');
expect(trigger.hasAttribute('data-placeholder')).toBe(true);
expect(trigger.querySelector('span.truncate')).not.toBeNull();
}
});
it('shows the device status as a Badge', async () => {
await render();
const badge = container.querySelector('[data-slot="badge"]');
expect(badge?.textContent).toBe('settings.devices.online');
expect(badge?.getAttribute('data-variant')).toBe('success');
});
it('renders Save draft and Publish as pill button variants', async () => {
await render();
const draft = buttonByText('agents.form.buttons.saveDraft');
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
expect(draft.getAttribute('data-shape')).toBe('pill');
const publish = buttonByText('agents.form.buttons.publish');
expect(publish.getAttribute('data-variant')).toBe('default');
expect(publish.getAttribute('data-shape')).toBe('pill');
expect(publish.disabled).toBe(true);
});
// Item 42: the header's Cancel (shown once the form is dirty) is ghost.
it('renders the header Cancel as a ghost pill once the form changes', async () => {
await render();
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
await act(async () => setNativeValue(name, 'Support bot'));
const cancel = buttonByText('agents.form.buttons.cancel');
expect(cancel.getAttribute('data-variant')).toBe('ghost');
expect(cancel.getAttribute('data-shape')).toBe('pill');
});
it('shows a failed save as a destructive alert with an icon', async () => {
await render();
const draft = buttonByText('agents.form.buttons.saveDraft');
await act(async () => draft.click());
const alert = container.querySelector('[role="alert"]')!;
expect(alert.textContent).toContain('Name is taken');
// ui/alert's destructive variant.
expect(alert.className).toContain('border-destructive/50');
expect(alert.className).toContain('bg-destructive/10');
expect(alert.querySelector('svg.lucide-circle-x')).not.toBeNull();
// Decision 69 (b): the notice sits above the form panel, not in the
// header's button row.
expect(draft.parentElement?.contains(alert)).toBe(false);
});
it('marks JSON schema validity with token colours and lucide icons', async () => {
await render();
await act(async () =>
buttonByText('agents.form.sections.advanced').click(),
);
const schema = Array.from(container.querySelectorAll('textarea')).find(
(el) => el.className.includes('font-mono'),
)!;
expect(schema.getAttribute('data-slot')).toBe('textarea');
expect(schema.getAttribute('data-size')).toBe('lg');
await act(async () => setNativeValue(schema, '{ not json'));
const invalid = Array.from(container.querySelectorAll('div')).find(
(el) => el.textContent === 'agents.form.advanced.invalidJson',
)!;
expect(invalid.className).toContain('text-destructive');
expect(invalid.querySelector('svg.lucide-circle-x')).not.toBeNull();
await act(async () => setNativeValue(schema, '{}'));
const valid = Array.from(container.querySelectorAll('div')).find(
(el) => el.textContent === 'agents.form.advanced.validJson',
)!;
expect(valid.className).toContain('text-success');
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
});
it('shows the preview placeholder illustration as decorative images', async () => {
await render();
const images = container.querySelectorAll('img[alt=""]');
expect(images).toHaveLength(2);
for (const img of images) {
expect(img.getAttribute('aria-hidden')).toBe('true');
}
});
});
+303 -333
View File
@@ -1,9 +1,10 @@
import isEqual from 'lodash/isEqual';
import { MoreHorizontal } from 'lucide-react';
import { ChevronRight, CircleCheck, CircleX, Database } from 'lucide-react';
import React, {
useCallback,
useEffect,
useMemo,
useId,
useRef,
useState,
} from 'react';
@@ -11,13 +12,12 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Card } from '@/components/ui/card';
import { ActionMenu } from '@/components/ui/dropdown-menu';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
@@ -27,19 +27,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import devicesService from '../api/services/devicesService';
import modelService from '../api/services/modelService';
import userService from '../api/services/userService';
import SourceIcon from '../assets/source.svg';
import ScienceSparkDarkIcon from '../assets/science-spark-dark.svg';
import ScienceSparkIcon from '../assets/science-spark.svg';
import { FileUpload } from '../components/FileUpload';
import {
MultiSelectPopover,
type MultiSelectPopoverItem,
} from '../components/MultiSelectPopover';
import SourcesPopoverFooter from '../components/SourcesPopoverFooter';
import Spinner from '../components/Spinner';
import ToolIcon from '../components/ToolIcon';
import AgentDetailsModal from '../modals/AgentDetailsModal';
import ShareToTeamModal from '../teams/ShareToTeamModal';
@@ -173,6 +177,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const [submitError, setSubmitError] = useState<string | null>(null);
const [jsonSchemaText, setJsonSchemaText] = useState('');
const [jsonSchemaValid, setJsonSchemaValid] = useState(true);
const tokenLimitSwitchId = useId();
const requestLimitSwitchId = useId();
const promptOverrideSwitchId = useId();
const [isAdvancedSectionExpanded, setIsAdvancedSectionExpanded] =
useState(false);
@@ -209,7 +216,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
};
const agentTypes = [
{ label: t('agents.form.agentTypes.classic'), value: 'classic' },
{ label: 'Research', value: 'research' },
{ label: t('agents.form.agentTypes.research'), value: 'research' },
];
const isPublishable = () => {
@@ -257,7 +264,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
own: t('agents.form.sourcePopup.groupOwn'),
team: t('agents.form.sourcePopup.groupTeam'),
},
SourceIcon,
<Database />,
);
// An attached source the caller can't list — an owner's private source on
// a team-shared agent — still needs a row of its own, or it reads as
@@ -265,7 +272,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const listed = new Set(items.map((item) => item.id));
const unlisted = Array.from(selectedSourceIds)
.filter((id) => !listed.has(id))
.map((id) => ({ id, label: resolveSourceLabel(id), icon: SourceIcon }));
.map((id) => ({ id, label: resolveSourceLabel(id), icon: <Database /> }));
return [...items, ...unlisted];
}, [resolveSourceLabel, selectedSourceIds, sourceDocs, t]);
@@ -581,7 +588,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const base: MultiSelectPopoverItem = {
id: tool.id,
label: getToolDisplayName(tool),
icon: <ToolIcon name={tool.name} className="h-5 w-5" />,
icon: <ToolIcon name={tool.name} className="size-5" />,
group: groupFor(tool),
};
if (tool.name === 'remote_device') {
@@ -589,17 +596,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const meta = devicesById.get(deviceId);
const online = meta?.online ?? false;
base.descriptionNode = (
<span
className={`mt-0.5 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
online
? 'bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-200 text-gray-700 dark:bg-gray-700/40 dark:text-gray-300'
}`}
<Badge
variant={online ? 'success' : 'neutral'}
className="mt-0.5"
>
{online
? t('settings.devices.online')
: t('settings.devices.offline')}
</span>
</Badge>
);
}
return base;
@@ -799,7 +803,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const showAgentNav = effectiveMode === 'edit' && Boolean(agent.id);
return (
<div className="flex flex-col p-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:p-12 md:pt-4 md:pb-3">
<div className="flex min-h-dvh flex-col p-4 pb-2 md:p-12 md:pt-4 md:pb-3 xl:h-dvh">
{agent.agent_type === 'workflow' && (
<div className="mt-4 w-full">
<WorkflowBuilder />
@@ -808,21 +812,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="flex w-full flex-wrap items-center justify-between gap-2">
{showAgentNav ? <CurrentSectionHeader /> : <span aria-hidden />}
<div className="flex flex-wrap items-center gap-2">
{submitError && (
<div
role="alert"
className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
>
<span className="h-4 w-4 shrink-0 bg-[url('/src/assets/circle-x.svg')] bg-contain bg-center bg-no-repeat" />
{submitError}
</div>
)}
{hasChanges && (
<Button
type="button"
variant="ghost"
shape="pill"
onClick={handleCancel}
className="text-primary dark:text-foreground rounded-3xl px-2 hover:bg-transparent"
>
{t('agents.form.buttons.cancel')}
</Button>
@@ -830,110 +825,100 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{modeConfig[effectiveMode].showSaveDraft && (
<Button
type="button"
variant="outline-primary"
shape="pill"
disabled={isDraftBlocked()}
loading={draftLoading}
onClick={handleSaveDraft}
className={`border-primary text-primary hover:bg-primary/90 min-w-28 rounded-3xl border border-solid bg-transparent px-5 whitespace-nowrap hover:text-white ${
isDraftBlocked() ? 'disabled:opacity-30' : ''
}`}
>
<span className="flex items-center justify-center transition-all duration-200">
{draftLoading ? (
<Spinner size="small" />
) : (
t('agents.form.buttons.saveDraft')
)}
</span>
{t('agents.form.buttons.saveDraft')}
</Button>
)}
<Button
type="button"
disabled={!isPublishable() || !hasChanges}
loading={publishLoading}
onClick={handlePublish}
className={`${!isPublishable() || !hasChanges ? 'disabled:opacity-30' : ''} min-w-28 rounded-3xl px-5 whitespace-nowrap text-white`}
shape="pill"
>
<span className="flex items-center justify-center transition-all duration-200">
{publishLoading ? (
<Spinner size="small" />
) : (
modeConfig[effectiveMode].buttonText
)}
</span>
{modeConfig[effectiveMode].buttonText}
</Button>
{modeConfig[effectiveMode].showAccessDetails && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('agents.form.buttons.moreActions')}
title={t('agents.form.buttons.moreActions')}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setAgentDetails('ACTIVE')}>
{t('agents.form.buttons.accessDetails')}
</DropdownMenuItem>
{/* Sharing is owner-only — hidden for agents shared into the
workspace by a team (ownership === 'team'). */}
{agent.ownership !== 'team' && agent.id && (
<DropdownMenuItem onSelect={() => setShareModalOpen(true)}>
{t('agents.shareWithTeam')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ActionMenu
triggerLabel={t('agents.form.buttons.moreActions')}
options={[
{
label: t('agents.form.buttons.accessDetails'),
onClick: () => setAgentDetails('ACTIVE'),
},
// Sharing is owner-only — hidden for agents shared into the
// workspace by a team (ownership === 'team').
...(agent.ownership !== 'team' && agent.id
? [
{
label: t('agents.shareWithTeam'),
onClick: () => setShareModalOpen(true),
},
]
: []),
]}
/>
)}
</div>
</div>
{showAgentNav && <SectionPills className="mt-4" />}
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.meta')}
</h2>
<Input
className="bg-card mt-3 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm"
type="text"
value={agent.name}
placeholder={t('agents.form.placeholders.agentName')}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="border-border text-foreground dark:text-foreground dark:placeholder:text-muted-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-3 h-32 w-full rounded-xl border px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 focus-visible:ring-[3px]"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
<div className="mt-3">
{submitError && (
<Alert variant="destructive" className="mt-3">
<CircleX aria-hidden="true" className="size-4" />
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
<div className="bg-muted mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 xl:grid xl:gap-5 xl:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 xl:max-h-full xl:overflow-y-auto xl:pr-3">
<div className="bg-card flex flex-col gap-5 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.meta')} />
<div className="flex flex-col gap-5">
<FormField label={t('agents.form.labels.name')}>
<Input
shape="pill"
type="text"
value={agent.name}
placeholder={t('agents.form.placeholders.agentName')}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
</FormField>
<FormField label={t('agents.form.labels.description')}>
<Textarea
size="lg"
className="h-32"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
</FormField>
<FileUpload
showPreview
className="bg-card"
size="compact"
previewSize={56}
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{
text: t('agents.form.upload.clickToUpload'),
colorClass: 'text-primary',
highlight: true,
},
{
text: t('agents.form.upload.dragAndDrop'),
colorClass: 'text-[#525252]',
},
]}
/>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.source')}
</h2>
<div className="mt-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.source')} />
<div>
<div className="flex flex-wrap items-center gap-1">
<MultiSelectPopover
open={isSourcePopupOpen}
@@ -960,22 +945,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={sourceAnchorButtonRef}
title={selectedSourceNames.join(', ')}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedSourceIds.size > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={
selectedSourceIds.size > 0 ? undefined : ''
}
className="w-full justify-start text-left"
>
{sourceTriggerLabel}
<span
className="truncate"
title={selectedSourceNames.join(', ')}
>
{sourceTriggerLabel}
</span>
</Button>
}
/>
</div>
{selectedSourceIds.size === 0 && (
<p className="text-muted-foreground mt-2 text-xs">
<p className="text-muted-foreground mt-1.5 text-xs">
{t('agents.form.sourcePopup.noSourceHint')}
</p>
)}
@@ -1009,25 +999,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))}
title={t('agents.form.sections.prompt')}
titleClassName="text-lg font-semibold"
titleAs="heading"
showAddButton={false}
dropdownProps={{ className: 'w-full' }}
/>
</div>
<Button
type="button"
variant="outline-primary"
size="field"
shape="pill"
onClick={() => setAddPromptModal('ACTIVE')}
className="border-primary text-primary hover:bg-primary/90 h-auto min-w-20 shrink-0 basis-full rounded-3xl border border-solid bg-transparent px-5 py-3 whitespace-nowrap hover:text-white sm:basis-auto"
className="min-w-20 shrink-0 basis-full sm:basis-auto"
>
{t('agents.form.buttons.add')}
</Button>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.tools')}
</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.tools')} />
<div className="flex flex-wrap items-center gap-1">
<MultiSelectPopover
open={isToolsPopupOpen}
onOpenChange={setIsToolsPopupOpen}
@@ -1059,40 +1050,36 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={toolAnchorButtonRef}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedTools.length > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={selectedTools.length > 0 ? undefined : ''}
className="w-full justify-start text-left"
>
{selectedTools.length > 0
? selectedTools
.map((tool) => getToolDisplayName(tool))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectTools')}
<span className="truncate">
{selectedTools.length > 0
? selectedTools
.map((tool) => getToolDisplayName(tool))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectTools')}
</span>
</Button>
}
/>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.agentType')}
</h2>
<div className="mt-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.agentType')} />
<div>
<Select
value={agent.agent_type || undefined}
onValueChange={(value) =>
setAgent({ ...agent, agent_type: value })
}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
size="lg"
>
<SelectTrigger className="w-full" shape="pill" size="field">
<SelectValue
placeholder={t('agents.form.placeholders.selectType')}
/>
@@ -1107,11 +1094,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</Select>
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.models')}
</h2>
<div className="mt-3 flex flex-col gap-3">
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
<SectionHeader title={t('agents.form.sections.models')} />
<div className="flex flex-col gap-3">
<MultiSelectPopover
open={isModelsPopupOpen}
onOpenChange={setIsModelsPopupOpen}
@@ -1148,38 +1133,35 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trigger={
<Button
type="button"
variant="outline"
variant="combobox"
size="field"
shape="pill"
ref={modelAnchorButtonRef}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedModelIds.size > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
data-placeholder={
selectedModelIds.size > 0 ? undefined : ''
}
className="w-full justify-start text-left"
>
{selectedModelIds.size > 0
? availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => m.display_name)
.join(', ')
: t('agents.form.placeholders.selectModels')}
<span className="truncate">
{selectedModelIds.size > 0
? availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => m.display_name)
.join(', ')
: t('agents.form.placeholders.selectModels')}
</span>
</Button>
}
/>
{selectedModelIds.size > 0 && (
<div>
<label className="mb-2 block text-sm font-medium">
{t('agents.form.labels.defaultModel')}
</label>
<FormField label={t('agents.form.labels.defaultModel')}>
<Select
value={agent.default_model_id || undefined}
onValueChange={(value) =>
setAgent({ ...agent, default_model_id: value })
}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
size="lg"
>
<SelectTrigger className="w-full" shape="pill" size="field">
<SelectValue
placeholder={t(
'agents.form.placeholders.selectDefaultModel',
@@ -1196,56 +1178,47 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
))}
</SelectContent>
</Select>
</div>
</FormField>
)}
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
<Button
type="button"
variant="ghost"
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
}
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
>
<div>
<h2 className="text-lg font-semibold">
<div className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
<h2>
<Button
type="button"
variant="section-toggle"
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
}
size="sm"
aria-expanded={isAdvancedSectionExpanded}
className="-ml-3 w-fit justify-start"
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
isAdvancedSectionExpanded && 'rotate-90',
)}
/>
<span className="text-lg font-semibold">
{t('agents.form.sections.advanced')}
</h2>
</div>
<div className="ml-4 flex items-center">
<svg
className={`size-5 transform transition-transform duration-200 ${
isAdvancedSectionExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</Button>
</span>
</Button>
</h2>
{isAdvancedSectionExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.jsonSchema')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.jsonSchemaDescription')}
</p>
</div>
<textarea
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
<div className="mt-5">
<FormField
label={t('agents.form.advanced.jsonSchema')}
hint={t('agents.form.advanced.jsonSchemaDescription')}
>
<Textarea
size="lg"
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
"type": "object",
"properties": {
"name": {"type": "string"},
@@ -1254,41 +1227,59 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
"required": ["name", "email"],
"additionalProperties": false
}`}
rows={9}
className={`border-border text-foreground dark:text-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-2 w-full rounded-2xl border px-4 py-3 font-mono text-sm outline-hidden focus-visible:ring-[3px]`}
/>
rows={9}
className="font-mono"
/>
</FormField>
{jsonSchemaText.trim() !== '' && (
<div
className={`mt-2 flex items-center gap-2 text-sm ${
jsonSchemaValid
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
className={cn(
'mt-2 flex items-center gap-2 text-sm',
jsonSchemaValid ? 'text-success' : 'text-destructive',
)}
>
<span
className={`h-4 w-4 bg-contain bg-center bg-no-repeat ${
jsonSchemaValid
? "bg-[url('/src/assets/circle-check.svg')]"
: "bg-[url('/src/assets/circle-x.svg')]"
}`}
/>
{jsonSchemaValid ? (
<CircleCheck className="size-4" aria-hidden="true" />
) : (
<CircleX className="size-4" aria-hidden="true" />
)}
{jsonSchemaValid
? t('agents.form.advanced.validJson')
: t('agents.form.advanced.invalidJson')}
</div>
)}
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.tokenLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.tokenLimitingDescription')}
</p>
</div>
<SettingRows className="mt-6">
<SettingRow
label={t('agents.form.advanced.tokenLimiting')}
description={t(
'agents.form.advanced.tokenLimitingDescription',
)}
htmlFor={tokenLimitSwitchId}
after={
<Input
type="number"
min="0"
value={agent.token_limit || ''}
onChange={(e) =>
setAgent({
...agent,
token_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
disabled={!agent.limited_token_mode}
placeholder={t(
'agents.form.placeholders.enterTokenLimit',
)}
aria-label={t('agents.form.advanced.tokenLimit')}
shape="pill"
/>
}
>
<Switch
id={tokenLimitSwitchId}
checked={agent.limited_token_mode}
onCheckedChange={(checked) => {
setAgent({
@@ -1300,40 +1291,37 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
});
}}
/>
</div>
<Input
type="number"
min="0"
value={agent.token_limit || ''}
onChange={(e) =>
setAgent({
...agent,
token_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
</SettingRow>
<SettingRow
label={t('agents.form.advanced.requestLimiting')}
description={t(
'agents.form.advanced.requestLimitingDescription',
)}
htmlFor={requestLimitSwitchId}
after={
<Input
type="number"
min="0"
value={agent.request_limit || ''}
onChange={(e) =>
setAgent({
...agent,
request_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
disabled={!agent.limited_request_mode}
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
)}
aria-label={t('agents.form.advanced.requestLimit')}
shape="pill"
/>
}
disabled={!agent.limited_token_mode}
placeholder={t('agents.form.placeholders.enterTokenLimit')}
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
!agent.limited_token_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.requestLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.requestLimitingDescription')}
</p>
</div>
>
<Switch
id={requestLimitSwitchId}
checked={agent.limited_request_mode}
onCheckedChange={(checked) => {
setAgent({
@@ -1345,45 +1333,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
});
}}
/>
</div>
<Input
type="number"
min="0"
value={agent.request_limit || ''}
onChange={(e) =>
setAgent({
...agent,
request_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
disabled={!agent.limited_request_mode}
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
</SettingRow>
<SettingRow
label={t('agents.form.advanced.systemPromptOverride')}
description={t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
!agent.limited_request_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-medium">
{t('agents.form.advanced.systemPromptOverride')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
</p>
</div>
htmlFor={promptOverrideSwitchId}
>
<Switch
className="shrink-0"
id={promptOverrideSwitchId}
checked={agent.allow_system_prompt_override}
onCheckedChange={(checked) =>
setAgent({
@@ -1392,8 +1351,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
})
}
/>
</div>
</div>
</SettingRow>
</SettingRows>
</div>
)}
</div>
@@ -1418,31 +1377,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
/>
{modeConfig[effectiveMode].showDelete && agent.id && (
<div className="border-destructive/40 bg-destructive/5 rounded-2xl border px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-destructive text-lg font-semibold">
{t('agents.form.dangerZone.heading')}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.form.dangerZone.description')}
</p>
</div>
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => setDeleteConfirmation('ACTIVE')}
className="shrink-0"
>
{t('agents.form.dangerZone.deleteButton')}
</Button>
</div>
</div>
<Card
tone="destructive"
padding="lg"
className="flex-row flex-wrap items-start justify-between"
>
<SectionHeader
tone="destructive"
title={t('agents.form.dangerZone.heading')}
description={t('agents.form.dangerZone.description')}
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => setDeleteConfirmation('ACTIVE')}
className="shrink-0"
>
{t('agents.form.dangerZone.deleteButton')}
</Button>
</Card>
)}
</div>
<div className="col-span-3 flex flex-col gap-2 max-[1179px]:h-auto max-[1179px]:px-0 max-[1179px]:py-0 min-[1180px]:h-full min-[1180px]:py-2">
<div className="flex-1 max-[1179px]:overflow-visible min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
<div className="col-span-3 flex flex-col gap-2 xl:h-full xl:py-2">
<div className="flex-1 xl:min-h-0 xl:overflow-hidden">
<AgentPreviewArea />
</div>
</div>
@@ -1457,7 +1416,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('agents.form.buttons.cancel')}
variant="danger"
variant="destructive"
/>
<AgentDetailsModal
agent={agent}
@@ -1501,14 +1460,25 @@ function AgentPreviewArea() {
const { t } = useTranslation();
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="bg-card border-border w-full rounded-2xl border max-[1179px]:h-[600px] min-[1180px]:h-full">
<div className="bg-card border-border h-[600px] w-full rounded-2xl border xl:h-full">
{selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl">
<AgentPreview />
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<img
src={ScienceSparkIcon}
alt=""
aria-hidden="true"
className="block size-12 dark:hidden"
/>
<img
src={ScienceSparkDarkIcon}
alt=""
aria-hidden="true"
className="hidden size-12 dark:block"
/>{' '}
<p className="text-muted-foreground text-xs">
{t('agents.form.preview.publishedPreview')}
</p>
+8 -25
View File
@@ -4,11 +4,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import MessageInput from '../components/MessageInput';
import { Avatar } from '../components/ui/avatar';
import Spinner from '../components/Spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
import {
@@ -18,7 +17,6 @@ import {
selectQueries,
selectStatus,
} from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import { selectToken, setSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import SharedAgentCard from './SharedAgentCard';
@@ -28,7 +26,6 @@ export default function SharedAgent() {
const { t } = useTranslation();
const { agentId } = useParams();
const dispatch = useDispatch<AppDispatch>();
const [isDarkTheme] = useDarkTheme();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
@@ -128,34 +125,20 @@ export default function SharedAgent() {
if (sharedAgent) dispatch(setSelectedAgent(sharedAgent));
}, [sharedAgent, dispatch]);
if (isLoading)
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
if (isLoading) return <LoadingState fill="parent" />;
if (!sharedAgent)
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col items-center justify-center gap-4">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No agent found"
className="mx-auto mb-6 h-32 w-32"
/>
<p className="text-muted-foreground text-center text-lg">
{t('agents.shared.notFound')}
</p>
</div>
</div>
<EmptyState className="h-full" title={t('agents.shared.notFound')} />
);
return (
<div className="relative h-full w-full">
<div className="absolute top-5 left-4 hidden items-center gap-3 sm:flex">
<Avatar
src={sharedAgent.image}
alt="agent-logo"
imgClassName="h-6 w-6 rounded-full object-contain"
alt={t('agents.shared.logoAlt')}
shape="circle"
className="overflow-hidden"
imgClassName="size-6 object-contain"
/>
<h2 className="text-foreground text-lg font-semibold">
{sharedAgent.name}
+34 -20
View File
@@ -1,9 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Pencil } from 'lucide-react';
import EditIcon from '../assets/edit.svg';
import ToolIcon from '../components/ToolIcon';
import { Avatar } from '../components/ui/avatar';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { formatDateTime } from '../utils/dateTimeUtils';
import { getToolDisplayName } from '../utils/toolUtils';
import { Agent } from './types';
@@ -23,14 +25,19 @@ export default function SharedAgentCard({
agent.shared_metadata !== null &&
Object.keys(agent.shared_metadata).length > 0;
return (
<div className="border-border dark:border-border flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
<Card
variant="subtle"
padding="lg"
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
>
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<div className="flex size-12 items-center justify-center overflow-hidden rounded-full p-1">
<Avatar
src={agent.image}
alt={agent.name}
className="h-full w-full"
imgClassName="h-full w-full rounded-full object-contain"
shape="circle"
className="size-full overflow-hidden"
imgClassName="size-full object-contain"
/>
</div>
<div className="flex max-h-[92px] flex-1 flex-col gap-px">
@@ -46,51 +53,58 @@ export default function SharedAgentCard({
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onEdit}
className="shrink-0 gap-1.5 rounded-full"
className="shrink-0"
aria-label={t('agents.edit')}
>
<img src={EditIcon} alt="" className="h-3.5 w-3.5" />
<Pencil />
{t('agents.edit')}
</Button>
)}
</div>
{hasSharedMetadata && (
<div className="mt-4 flex items-center gap-8">
<div className="mt-1 flex items-center gap-8">
{agent.shared_metadata?.shared_by && (
<p className="text-foreground text-xs font-light sm:text-sm">
by {agent.shared_metadata.shared_by}
{t('agents.shared.sharedBy', {
name: agent.shared_metadata.shared_by,
interpolation: { escapeValue: false },
})}
</p>
)}
{agent.shared_metadata?.shared_at && (
<p className="text-muted-foreground text-xs font-light sm:text-sm">
Shared on {formatDateTime(agent.shared_metadata.shared_at)}
{t('agents.shared.sharedOn', {
date: formatDateTime(agent.shared_metadata.shared_at),
interpolation: { escapeValue: false },
})}
</p>
)}
</div>
)}
{agent.tool_details && agent.tool_details.length > 0 && (
<div className="mt-8">
<div className="mt-5">
<p className="text-foreground text-sm font-semibold sm:text-base">
Connected Tools
{t('agents.shared.connectedTools')}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{agent.tool_details.map((tool, index) => (
<span
key={index}
className="bg-accent text-foreground dark:bg-card flex items-center gap-1 rounded-full px-3 py-1 text-xs font-light"
>
<Badge key={index} variant="default">
<ToolIcon
name={tool.name}
title={`${getToolDisplayName(tool)} icon`}
className="h-3 w-3"
title={t('agents.shared.toolIconTitle', {
name: getToolDisplayName(tool),
interpolation: { escapeValue: false },
})}
className="size-3"
/>{' '}
{getToolDisplayName(tool)}
</span>
</Badge>
))}
</div>
</div>
)}
</div>
</Card>
);
}
@@ -0,0 +1,72 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const navigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigate,
}));
import AgentTypeModal from './AgentTypeModal';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentTypeModal', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
navigate.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (onClose = vi.fn()) => {
await act(async () => {
root.render(<AgentTypeModal isOpen onClose={onClose} folderId="f1" />);
});
return onClose;
};
const tiles = () =>
Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
'[data-slot="option-card"]',
),
);
it('renders the two agent types as OptionCards', async () => {
await render();
const found = tiles();
expect(found).toHaveLength(2);
expect(found[0].textContent).toContain('agents.typeModal.classicTitle');
expect(found[1].textContent).toContain('agents.typeModal.workflowTitle');
expect(found[0].querySelector('[data-slot="option-card-icon"]')).not.toBe(
null,
);
expect(found[0].querySelector('[data-slot="card-description"]')).not.toBe(
null,
);
});
it('navigates to the workflow builder and closes', async () => {
const onClose = await render();
await act(async () => tiles()[1].click());
expect(navigate).toHaveBeenCalledTimes(1);
expect(String(navigate.mock.calls[0][0])).toContain('workflow');
expect(onClose).toHaveBeenCalled();
});
it('navigates to the classic form and closes', async () => {
const onClose = await render();
await act(async () => tiles()[0].click());
expect(navigate).toHaveBeenCalledTimes(1);
expect(String(navigate.mock.calls[0][0])).not.toContain('workflow');
expect(onClose).toHaveBeenCalled();
});
});
@@ -1,8 +1,10 @@
import { Bot, Workflow } from 'lucide-react';
import { agentNewPath } from '../paths';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/ui/modal';
import { OptionCard } from '../../components/ui/option-card';
interface AgentTypeModalProps {
isOpen: boolean;
@@ -15,6 +17,7 @@ export default function AgentTypeModal({
onClose,
folderId,
}: AgentTypeModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSelect = (type: 'normal' | 'workflow') => {
@@ -30,48 +33,23 @@ export default function AgentTypeModal({
<Modal
open={isOpen}
onOpenChange={(o) => !o && onClose()}
title="Create New Agent"
description="Choose the type of agent you want to create"
title={t('agents.typeModal.title')}
description={t('agents.typeModal.description')}
size="md"
>
<div className="flex flex-col gap-4">
<button
type="button"
<OptionCard
icon={<Bot />}
title={t('agents.typeModal.classicTitle')}
description={t('agents.typeModal.classicDescription')}
onClick={() => handleSelect('normal')}
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
>
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary/90 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Bot size={28} />
</div>
<div className="flex-1">
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
Classic Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Create a standard AI agent with a single model, tools, and
knowledge sources
</p>
</div>
</button>
<button
type="button"
/>
<OptionCard
icon={<Workflow />}
title={t('agents.typeModal.workflowTitle')}
description={t('agents.typeModal.workflowDescription')}
onClick={() => handleSelect('workflow')}
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
>
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Workflow size={28} />
</div>
<div className="flex-1">
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
Workflow Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Design complex multi-step workflows with different models,
conditional logic, and state management
</p>
</div>
</button>
/>
</div>
</Modal>
);
@@ -0,0 +1,186 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
// A stable `t`: the component lists it as an effect dependency.
vi.mock('react-i18next', () => {
const t = (key: string) => key;
return { useTranslation: () => ({ t }) };
});
const events = [
{ id: '1', action: 'block', outcome: 'triggered' },
{ id: '2', action: 'redact', outcome: 'triggered' },
{ id: '3', action: 'flag', outcome: 'triggered' },
{ id: '4', action: 'flag', outcome: 'not_evaluated' },
].map((e) => ({
...e,
check_name: `check-${e.id}`,
stage: 'input',
category: null,
detail: '',
created_at: '2026-09-01T00:00:00Z',
}));
// Flipped by the Retry test to make the next events fetch fail.
const fetchState = vi.hoisted(() => ({ failNext: false }));
vi.mock('../../api/services/userService', () => ({
default: {
getGuardrailEvents: () => {
if (fetchState.failNext) {
fetchState.failNext = false;
return Promise.reject(new Error('network'));
}
return Promise.resolve({
json: () => Promise.resolve({ success: true, events }),
});
},
getGuardrailSummary: () =>
Promise.resolve({
json: () =>
Promise.resolve({
success: true,
breakdown: [],
totals: { blocked: 1, redacted: 1, flagged: 1, not_evaluated: 1 },
}),
}),
},
}));
import GuardrailEvents from './GuardrailEvents';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeStore = () =>
configureStore({ reducer: { preference: () => ({ token: null }) } });
describe('GuardrailEvents tones', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(async () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(
<Provider store={makeStore()}>
<GuardrailEvents agentId="a1" />
</Provider>,
);
});
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('colours outcome badges by meaning', () => {
const variants = Array.from(
container.querySelectorAll(
'[data-testid="guardrail-events-rows"] [data-slot="badge"]',
),
).map((b) => b.getAttribute('data-variant'));
expect(variants).toEqual(['destructive', 'info', 'warning', 'neutral']);
});
it('colours the stat tiles to match', () => {
const tone = (id: string) =>
container.querySelector(`[data-testid="${id}"] p:last-child`)?.className;
expect(tone('guardrail-stat-blocked')).toContain('text-destructive');
expect(tone('guardrail-stat-redacted')).toContain('text-info');
expect(tone('guardrail-stat-flagged')).toContain('text-warning');
expect(tone('guardrail-stat-not-evaluated')).toContain(
'text-muted-foreground',
);
});
it('renders the stat tiles as StatCards', () => {
for (const id of [
'guardrail-stat-blocked',
'guardrail-stat-redacted',
'guardrail-stat-flagged',
'guardrail-stat-not-evaluated',
]) {
const tile = container.querySelector(`[data-testid="${id}"]`);
expect(tile?.getAttribute('data-slot')).toBe('card');
expect(tile?.getAttribute('data-variant')).toBe('subtle');
}
expect(
container
.querySelector('[data-testid="guardrail-stat-not-evaluated"]')
?.getAttribute('title'),
).toBe('agents.guardrailEvents.notEvaluatedHint');
});
it('puts the window Select in the section header actions', () => {
const header = container.querySelector('[data-slot="section-header"]');
expect(
header?.querySelector('[data-testid="guardrail-events-window"]'),
).not.toBeNull();
});
it('renders ui/table named by the Guardrail decisions sub-heading', () => {
const table = container.querySelector('table[data-slot="table"]');
expect(table).not.toBeNull();
const labelId = table!.getAttribute('aria-labelledby');
expect(labelId).toBeTruthy();
// SectionHeader puts the id on its root; with no actions or description
// the root holds only the h3, so the name is the heading's text.
const label = document.getElementById(labelId!);
expect(label?.querySelector('h3')?.textContent).toBe(
'agents.guardrailEvents.tableHeader',
);
expect(label?.textContent).toBe('agents.guardrailEvents.tableHeader');
expect(table!.querySelector('[data-slot="table-head"]')).not.toBeNull();
expect(
table!.querySelector(
'[data-slot="table-body"][data-testid="guardrail-events-rows"]',
),
).not.toBeNull();
});
});
describe('GuardrailEvents load error', () => {
it('shows a destructive empty state whose Retry re-runs the fetch', async () => {
fetchState.failNext = true;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<Provider store={makeStore()}>
<GuardrailEvents agentId="a1" />
</Provider>,
);
});
const errorState = container.querySelector(
'[data-slot="empty-state"][data-tone="destructive"]',
);
expect(errorState?.textContent).toContain(
'agents.guardrailEvents.loadError',
);
const retry = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
);
expect(retry).toBeDefined();
await act(async () => retry!.click());
expect(
container.querySelector(
'[data-slot="empty-state"][data-tone="destructive"]',
),
).toBeNull();
expect(
container.querySelector('[data-testid="guardrail-events-rows"]'),
).not.toBeNull();
await act(async () => root.unmount());
container.remove();
});
});
+135 -149
View File
@@ -9,6 +9,20 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Progress } from '@/components/ui/progress';
import { SectionHeader } from '@/components/ui/section-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import StatCard from '@/components/StatCard';
import userService from '../../api/services/userService';
import SkeletonLoader from '../../components/SkeletonLoader';
@@ -32,15 +46,18 @@ const ACTION_KEYS: Record<string, string> = {
block: 'agents.form.guardrails.actions.block',
};
/** Colour by consequence, so "we refused" reads differently from "we noticed". */
function actionTone(action: string, outcome: string): string {
if (outcome === 'not_evaluated')
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
if (action === 'block')
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300';
if (action === 'redact')
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300';
/**
* Colour by meaning, matching the status tokens used across the app: a block
* is a refusal, a flag needs review, a redaction is informational.
*/
function actionTone(
action: string,
outcome: string,
): 'neutral' | 'destructive' | 'warning' | 'info' {
if (outcome === 'not_evaluated') return 'neutral';
if (action === 'block') return 'destructive';
if (action === 'redact') return 'info';
return 'warning';
}
type Props = { agentId?: string };
@@ -56,6 +73,8 @@ export default function GuardrailEvents({ agentId }: Props) {
const [days, setDays] = React.useState(30);
const [checkFilter, setCheckFilter] = React.useState('all');
const [outcomeFilter, setOutcomeFilter] = React.useState('all');
// Bumped by Retry to re-run the fetch below.
const [reloadKey, setReloadKey] = React.useState(0);
React.useEffect(() => {
if (!agentId) return;
@@ -88,7 +107,7 @@ export default function GuardrailEvents({ agentId }: Props) {
return () => {
cancelled = true;
};
}, [agentId, token, days, t]);
}, [agentId, token, days, t, reloadKey]);
const checkNames = React.useMemo(
() => Array.from(new Set(events.map((e) => e.check_name))).sort(),
@@ -105,82 +124,88 @@ export default function GuardrailEvents({ agentId }: Props) {
);
const totals = summary?.totals;
const tableHeadingId = React.useId();
return (
<div className="mt-8 px-4" data-testid="guardrail-events">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">
{t('agents.guardrailEvents.heading')}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t('agents.guardrailEvents.description')}
</p>
</div>
<Select
value={String(days)}
onValueChange={(value) => setDays(Number(value))}
>
<SelectTrigger
className="w-[150px] rounded-3xl px-5 py-3 text-sm"
size="lg"
data-testid="guardrail-events-window"
<SectionHeader
title={t('agents.guardrailEvents.heading')}
description={t('agents.guardrailEvents.description')}
actions={
<Select
value={String(days)}
onValueChange={(value) => setDays(Number(value))}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window} value={String(window)}>
{t('agents.guardrailEvents.lastDays', { count: window })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SelectTrigger
className="w-[150px]"
size="field"
shape="pill"
data-testid="guardrail-events-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window} value={String(window)}>
{t('agents.guardrailEvents.lastDays', { count: window })}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
{/* Blocked / flagged / redacted / not-evaluated are four different
product problems; a single "violations" number hides which one you
have. Not-evaluated in particular means a check silently stopped
working. */}
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label={t('agents.guardrailEvents.blocked')}
value={totals?.blocked}
value={totals?.blocked ?? 0}
loading={loading}
testId="guardrail-stat-blocked"
tone="text-red-700 dark:text-red-400"
data-testid="guardrail-stat-blocked"
valueTone="destructive"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.redacted')}
value={totals?.redacted}
value={totals?.redacted ?? 0}
loading={loading}
testId="guardrail-stat-redacted"
tone="text-amber-700 dark:text-amber-400"
data-testid="guardrail-stat-redacted"
valueTone="info"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.flagged')}
value={totals?.flagged}
value={totals?.flagged ?? 0}
loading={loading}
testId="guardrail-stat-flagged"
tone="text-emerald-700 dark:text-emerald-400"
data-testid="guardrail-stat-flagged"
valueTone="warning"
/>
<StatTile
<StatCard
label={t('agents.guardrailEvents.notEvaluated')}
value={totals?.not_evaluated}
value={totals?.not_evaluated ?? 0}
loading={loading}
testId="guardrail-stat-not-evaluated"
tone="text-muted-foreground"
data-testid="guardrail-stat-not-evaluated"
valueTone="muted"
hint={t('agents.guardrailEvents.notEvaluatedHint')}
/>
</div>
{summary && summary.breakdown.length > 0 && <ByCheck summary={summary} />}
<div className="mt-6 flex flex-wrap items-center gap-3">
<SectionHeader
as="h3"
size="xs"
id={tableHeadingId}
title={t('agents.guardrailEvents.tableHeader')}
className="mt-6"
/>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Select value={checkFilter} onValueChange={setCheckFilter}>
<SelectTrigger
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
size="lg"
className="w-[170px]"
size="field"
shape="pill"
data-testid="guardrail-events-check-filter"
>
<SelectValue />
@@ -198,8 +223,9 @@ export default function GuardrailEvents({ agentId }: Props) {
</Select>
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
<SelectTrigger
className="w-[170px] rounded-3xl px-5 py-3 text-sm"
size="lg"
className="w-[170px]"
size="field"
shape="pill"
data-testid="guardrail-events-outcome-filter"
>
<SelectValue />
@@ -224,19 +250,28 @@ export default function GuardrailEvents({ agentId }: Props) {
</Select>
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border dark:bg-black">
<div className="flex h-8 flex-col items-start justify-center bg-black/10 dark:bg-white/5">
<p className="text-muted-foreground px-3 text-xs">
{t('agents.guardrailEvents.tableHeader')}
</p>
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border">
<div className="max-h-[45vh] overflow-y-auto">
{loading ? (
<div className="p-3">
<SkeletonLoader count={3} />
</div>
) : error ? (
<p className="text-destructive p-4 text-sm">{error}</p>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={error}
action={
<Button
variant="outline"
size="sm"
onClick={() => setReloadKey((key) => key + 1)}
>
{t('retry')}
</Button>
}
/>
) : visible.length === 0 ? (
<p
className="text-muted-foreground p-4 text-sm"
@@ -247,65 +282,51 @@ export default function GuardrailEvents({ agentId }: Props) {
: t('agents.guardrailEvents.emptyForFilter')}
</p>
) : (
<table className="w-full text-left text-xs">
<thead className="text-muted-foreground">
<tr className="border-border border-b">
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.when')}
</th>
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.check')}
</th>
<th className="px-3 py-2 font-medium">
{t('agents.guardrailEvents.stage')}
</th>
<th className="px-3 py-2 font-medium">
<Table aria-labelledby={tableHeadingId}>
<TableHead>
<TableRow>
<TableHeader>{t('agents.guardrailEvents.when')}</TableHeader>
<TableHeader>{t('agents.guardrailEvents.check')}</TableHeader>
<TableHeader>{t('agents.guardrailEvents.stage')}</TableHeader>
<TableHeader>
{t('agents.guardrailEvents.outcome')}
</th>
<th className="px-3 py-2 font-medium">
</TableHeader>
<TableHeader>
{t('agents.guardrailEvents.detail')}
</th>
</tr>
</thead>
<tbody data-testid="guardrail-events-rows">
</TableHeader>
</TableRow>
</TableHead>
<TableBody data-testid="guardrail-events-rows">
{visible.map((event) => (
<tr
key={event.id}
className="border-border/60 border-b last:border-0"
>
<td className="text-muted-foreground px-3 py-2 whitespace-nowrap">
<TableRow key={event.id}>
<TableCell className="text-muted-foreground whitespace-nowrap">
{formatDateTime(event.created_at)}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell>
<span className="font-medium">{event.check_name}</span>
{event.category && (
<span className="text-muted-foreground ml-1">
· {event.category}
</span>
)}
</td>
<td className="text-muted-foreground px-3 py-2">
</TableCell>
<TableCell className="text-muted-foreground">
{t(STAGE_KEYS[event.stage] ?? event.stage)}
</td>
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${actionTone(
event.action,
event.outcome,
)}`}
>
</TableCell>
<TableCell>
<Badge variant={actionTone(event.action, event.outcome)}>
{event.outcome === 'not_evaluated'
? t('agents.guardrailEvents.notEvaluated')
: t(ACTION_KEYS[event.action] ?? event.action)}
</span>
</td>
<td className="text-muted-foreground max-w-[28rem] px-3 py-2">
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{event.detail || '—'}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</div>
@@ -318,39 +339,6 @@ export default function GuardrailEvents({ agentId }: Props) {
);
}
function StatTile({
label,
value,
loading,
tone,
testId,
hint,
}: {
label: string;
value?: number;
loading: boolean;
tone: string;
testId: string;
hint?: string;
}) {
return (
<div
className="border-border bg-card rounded-xl border px-4 py-3"
data-testid={testId}
title={hint}
>
<p className="text-muted-foreground text-xs">{label}</p>
{loading ? (
<div className="mt-1 h-6 w-10">
<SkeletonLoader count={1} />
</div>
) : (
<p className={`mt-1 text-xl font-semibold ${tone}`}>{value ?? 0}</p>
)}
</div>
);
}
/**
* Per-check totals. The point is to see *which* control is firing: a rollout's
* first week is spent finding the one that is over-triggering, and an
@@ -381,12 +369,10 @@ function ByCheck({ summary }: { summary: GuardrailSummary }) {
{rows.map(([check, total]) => (
<div key={check} className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-xs">{check}</span>
<div className="bg-border/60 h-2 flex-1 overflow-hidden rounded-full">
<div
className="bg-violets-are-blue h-full rounded-full"
style={{ width: `${max ? (total / max) * 100 : 0}%` }}
/>
</div>
<Progress
className="flex-1"
value={max ? (total / max) * 100 : 0}
/>
<span className="text-muted-foreground w-10 shrink-0 text-right text-xs">
{total}
</span>
@@ -0,0 +1,383 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { GuardrailCatalog, GuardrailsConfig } from '../types';
const getGuardrailCatalog = vi.fn();
vi.mock('../../api/services/userService', () => ({
default: {
getGuardrailCatalog: (...args: unknown[]) => getGuardrailCatalog(...args),
},
}));
// A stable `t`: the component refetches the catalog whenever `t` changes.
const t = (key: string) => key;
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t }),
}));
import GuardrailsSection from './GuardrailsSection';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const catalog: GuardrailCatalog = {
enabled: false,
checks: [
{
name: 'pii',
label: 'PII',
description: 'Detects personal data',
stages: ['input', 'output'],
supports_redaction: true,
latency_hint_ms: 50,
remote: false,
available: false,
},
{
name: 'policy',
label: 'Policy',
description: 'LLM judge',
stages: ['input'],
supports_redaction: false,
latency_hint_ms: 1500,
remote: true,
available: true,
},
],
stages: ['input', 'output'],
modes: ['monitor_only', 'scan_all'],
actions_by_stage: {
input: ['flag', 'block'],
retrieval: ['flag'],
tool_result: ['flag'],
output: ['flag', 'redact', 'block'],
},
default_block_message: 'No',
pii_entities: ['EMAIL', 'PHONE'],
default_pii_entities: ['EMAIL'],
floor: {
enabled: true,
mode: 'scan_all',
fail_open: true,
timeout_ms: 2000,
block_message: '',
controls: [
{
check: 'pii',
stage: 'output',
action: 'block',
enabled: true,
settings: {},
},
],
},
};
const config: GuardrailsConfig = {
enabled: true,
mode: 'monitor_only',
fail_open: true,
timeout_ms: 2000,
block_message: 'Sorry',
controls: [
{
check: 'pii',
stage: 'input',
action: 'flag',
enabled: true,
settings: { entities: ['EMAIL'] },
},
{
check: 'policy',
stage: 'input',
action: 'flag',
enabled: true,
settings: { policy: '' },
},
{
check: 'gone',
stage: 'input',
action: 'flag',
enabled: true,
settings: {},
},
],
};
describe('GuardrailsSection', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
getGuardrailCatalog.mockReset();
getGuardrailCatalog.mockResolvedValue({
json: async () => ({ success: true, ...catalog }),
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const q = (testId: string) =>
container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
const render = async (
props: Partial<Parameters<typeof GuardrailsSection>[0]> = {},
) => {
await act(async () => {
root.render(
<GuardrailsSection
value={config}
onChange={() => undefined}
token="tok"
disabledNotice="Read only"
{...props}
/>,
);
});
await act(async () => {
q('guardrails-toggle')?.click();
});
};
it('announces whether the section is open with aria-expanded', async () => {
await render();
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('true');
await act(async () => {
q('guardrails-toggle')?.click();
});
expect(q('guardrails-toggle')?.getAttribute('aria-expanded')).toBe('false');
});
// Decision 64: the header is a section-toggle with a leading lucide
// chevron; the badges sit beside it and the panel draws the focus ring.
it('renders the header as a section-toggle with the badges beside it', async () => {
await render();
const toggle = q('guardrails-toggle')!;
expect(toggle.dataset.variant).toBe('section-toggle');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
const chevron = toggle.firstElementChild!;
expect(chevron.matches('svg.lucide-chevron-right')).toBe(true);
expect(chevron.getAttribute('class')).toContain('rotate-90');
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
// The heading wraps the button (a button's children are presentational,
// so a heading inside it would be lost to screen readers).
expect(toggle.querySelector('h2')).toBeNull();
const heading = toggle.closest('h2')!;
expect(heading.textContent).toBe('agents.form.sections.guardrails');
const active = q('guardrails-active-badge')!;
const incomplete = q('guardrails-incomplete-badge')!;
expect(toggle.contains(active)).toBe(false);
expect(toggle.contains(incomplete)).toBe(false);
expect(active.parentElement).toBe(heading.parentElement);
expect(heading.parentElement?.firstElementChild).toBe(heading);
const panel = q('guardrails-section')!;
expect(panel.contains(toggle)).toBe(true);
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(panel.className).toContain('bg-card');
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
});
it('renders the header pills as badges in their status roles', async () => {
await render();
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
expect(q('guardrails-active-badge')?.dataset.variant).toBe('success');
expect(q('guardrails-incomplete-badge')?.dataset.variant).toBe(
'destructive',
);
expect(q('guardrail-latency-pii')?.dataset.variant).toBe('neutral');
expect(q('guardrail-latency-pii')?.title).toBe(
'agents.form.guardrails.latencyHint',
);
});
it('shows the section notices as polite or warning alerts', async () => {
await render({ disabled: true });
const readOnly = q('guardrails-read-only');
expect(readOnly?.getAttribute('role')).toBe('status');
expect(readOnly?.textContent).toContain('Read only');
const instance = q('guardrails-instance-disabled');
expect(instance?.getAttribute('role')).toBe('alert');
expect(instance?.className).toContain('text-warning');
const floor = Array.from(
container.querySelectorAll<HTMLElement>('[role]'),
).find((el) =>
el.textContent?.includes('agents.form.guardrails.floorNotice'),
);
expect(floor?.getAttribute('role')).toBe('status');
expect(floor?.className).toContain('text-info');
});
// Item 38: the instance-policy line is an info Alert announced as a note.
it('renders an instance-enforced control as an info note', async () => {
await render();
const note = q('guardrail-floor-pii:output')!;
expect(note.dataset.slot).toBe('alert');
expect(note.getAttribute('role')).toBe('note');
expect(note.className).toContain('text-info');
expect(note.querySelector('svg.lucide-info')).not.toBeNull();
expect(note.textContent).toContain('agents.form.guardrails.floorControl');
});
// Item 38: a control's setup error is its FormField error, tied to the
// action select.
it('announces the setup error under the control row', async () => {
await render();
const error = q('guardrail-needs-setup-policy-input')!;
expect(error.getAttribute('role')).toBe('alert');
expect(error.textContent).toBe('agents.form.guardrails.setupRequired');
expect(q('guardrail-needs-setup-pii-input')).toBeNull();
});
it('announces an empty PII entity list', async () => {
await render({
value: {
...config,
controls: [{ ...config.controls[0], settings: { entities: [] } }],
},
});
await act(async () => {
q('guardrail-configure-pii-input')?.click();
});
const errors = Array.from(container.querySelectorAll('[role="alert"]')).map(
(el) => el.textContent,
);
expect(errors).toContain('agents.form.guardrails.pickAtLeastOne');
});
it('uses the pressed-toggle variants for stage chips', async () => {
await render();
expect(q('guardrail-stage-pii-input')?.dataset.variant).toBe('secondary');
const locked = q('guardrail-stage-pii-output');
expect(locked?.dataset.variant).toBe('secondary');
expect((locked as HTMLButtonElement).disabled).toBe(true);
// The hint lives on the hoverable wrapper; the named button has no title.
expect(locked?.hasAttribute('title')).toBe(false);
expect(locked?.parentElement?.title).toBe(
'agents.form.guardrails.lockedByFloor',
);
expect(q('guardrail-stage-policy-input')?.dataset.variant).toBe(
'secondary',
);
});
it('renders remove actions as ghost-destructive xs buttons', async () => {
await render();
const remove = q('guardrail-remove-pii-input');
expect(remove?.dataset.variant).toBe('ghost-destructive');
expect(remove?.dataset.size).toBe('xs');
const orphan = q('guardrail-orphan-gone')?.querySelector('button');
// On the tinted orphan row the hover is a destructive tint, not grey.
expect(orphan?.dataset.variant).toBe('ghost-destructive-on-accent');
expect(orphan?.dataset.size).toBe('xs');
});
it('draws the orphan-guardrail row as a small destructive Card', async () => {
await render();
const row = q('guardrail-orphan-gone');
expect(row?.dataset.slot).toBe('card');
expect(row?.dataset.tone).toBe('destructive');
expect(row?.dataset.padding).toBe('sm');
});
// Decision 63 (3): the form's fields and selects are all 42px.
it('sizes the fields through Input and Textarea props', async () => {
await render();
const block = q('guardrails-block-message');
expect(block?.dataset.size).toBe('default');
expect(block?.dataset.shape).toBe('pill');
expect(block?.className).not.toContain('bg-card');
const timeout = q('guardrails-timeout');
expect(timeout?.dataset.variant).toBe('filled');
expect(timeout?.dataset.size).toBe('default');
expect(timeout?.dataset.shape).toBe('pill');
const mode = q('guardrails-mode');
expect(mode?.dataset.size).toBe('field');
expect(mode?.dataset.shape).toBe('pill');
await act(async () => {
q('guardrail-configure-policy-input')?.click();
});
expect(q('guardrail-policy-text')?.dataset.slot).toBe('textarea');
});
// Decision 63e-b: the monitor-only line is advice, so the field's muted hint.
it('explains monitor-only mode in the mode field hint', async () => {
await render();
const hint = Array.from(container.querySelectorAll('p')).find(
(p) => p.textContent === 'agents.form.guardrails.monitorHint',
)!;
expect(hint.className.split(' ')).toEqual(
expect.arrayContaining(['text-muted-foreground', 'text-xs']),
);
expect(hint.className).not.toContain('text-warning');
expect(q('guardrails-mode')?.getAttribute('aria-describedby')).toContain(
hint.id,
);
});
// Decision 63a: each control's name is a 14px semibold sub-heading.
it('titles each check card with an xs section header', async () => {
await render();
const title = q('guardrail-check-pii')?.querySelector('h4');
expect(title?.textContent).toBe('PII');
expect(title?.className.split(' ')).toEqual(
expect.arrayContaining(['text-foreground', 'text-sm', 'font-semibold']),
);
});
it('renders each check as a small-padded Card', async () => {
await render();
const card = q('guardrail-check-pii');
expect(card?.dataset.slot).toBe('card');
expect(card?.dataset.padding).toBe('sm');
});
it('shows PII entities as toggle chips', async () => {
await render();
await act(async () => {
q('guardrail-configure-pii-input')?.click();
});
expect(q('guardrail-pii-EMAIL')?.dataset.variant).toBe('secondary');
expect(q('guardrail-pii-PHONE')?.dataset.variant).toBe('ghost-muted');
});
it('shows a failed catalog load as a destructive empty state with Retry', async () => {
getGuardrailCatalog.mockReset();
getGuardrailCatalog.mockRejectedValueOnce(new Error('network'));
getGuardrailCatalog.mockResolvedValue({
json: async () => ({ success: true, ...catalog }),
});
await render();
const errorState = () =>
container.querySelector<HTMLElement>(
'[data-slot="empty-state"][data-tone="destructive"]',
);
expect(errorState()?.textContent).toContain(
'agents.form.guardrails.loadError',
);
expect(errorState()?.dataset.size).toBe('sm');
const retry = Array.from(errorState()!.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
);
await act(async () => retry!.click());
expect(getGuardrailCatalog).toHaveBeenCalledTimes(2);
expect(errorState()).toBeNull();
});
});
@@ -1,7 +1,13 @@
import React from 'react';
import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -10,7 +16,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { SettingRow } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import userService from '../../api/services/userService';
import {
@@ -108,11 +118,16 @@ export default function GuardrailsSection({
const [catalog, setCatalog] = React.useState<GuardrailCatalog | null>(null);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [openSettings, setOpenSettings] = React.useState<string | null>(null);
// Bumped by Retry to re-run the catalog fetch below.
const [reloadKey, setReloadKey] = React.useState(0);
const enabledId = React.useId();
const failOpenId = React.useId();
const config = value ?? DEFAULT_GUARDRAILS;
React.useEffect(() => {
let cancelled = false;
setLoadError(null);
userService
.getGuardrailCatalog(token)
.then((res) => res.json())
@@ -127,7 +142,7 @@ export default function GuardrailsSection({
return () => {
cancelled = true;
};
}, [token, t]);
}, [token, t, reloadKey]);
/** Floor-imposed controls, so the UI can show them as active and locked. */
const floorControls = React.useMemo(() => {
@@ -202,143 +217,162 @@ export default function GuardrailsSection({
return (
<div
className="bg-card rounded-2xl px-6 py-3"
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
data-testid="guardrails-section"
>
<Button
type="button"
variant="ghost"
onClick={() => setExpanded(!expanded)}
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
data-testid="guardrails-toggle"
>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.guardrails')}
</h2>
{config.enabled && (
<span
className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
data-testid="guardrails-active-badge"
>
{t('agents.form.guardrails.activeCount', {
count: config.controls.length + floorControls.size,
})}
</span>
)}
{incompleteCount > 0 && (
<span
className="bg-destructive/10 text-destructive rounded-full px-2 py-0.5 text-xs font-medium"
data-testid="guardrails-incomplete-badge"
>
{t('agents.form.guardrails.needsSetup', {
count: incompleteCount,
})}
</span>
)}
</div>
<div className="ml-4 flex items-center">
<svg
className={`size-5 transform transition-transform duration-200 ${
expanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex flex-wrap items-center gap-2">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
<h2>
<Button
type="button"
variant="section-toggle"
size="sm"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="-ml-3 w-fit justify-start"
data-testid="guardrails-toggle"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</svg>
</div>
</Button>
<span className="text-lg font-semibold">
{t('agents.form.sections.guardrails')}
</span>
</Button>
</h2>
{config.enabled && (
<Badge variant="success" data-testid="guardrails-active-badge">
{t('agents.form.guardrails.activeCount', {
count: config.controls.length + floorControls.size,
})}
</Badge>
)}
{incompleteCount > 0 && (
<Badge
variant="destructive"
data-testid="guardrails-incomplete-badge"
>
{t('agents.form.guardrails.needsSetup', {
count: incompleteCount,
})}
</Badge>
)}
</div>
{expanded && (
<div className="mt-3 pb-3">
{loadError && (
<p className="text-destructive mt-3 text-xs">{loadError}</p>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={loadError}
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setReloadKey((k) => k + 1)}
>
{t('retry')}
</Button>
}
/>
)}
{disabled && disabledNotice && (
<p
className="mt-3 rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300"
<Alert
role="status"
className="mt-3"
data-testid="guardrails-read-only"
>
{disabledNotice}
</p>
<Lock aria-hidden="true" className="size-4" />
<AlertDescription>{disabledNotice}</AlertDescription>
</Alert>
)}
{instanceDisabled && (
<p
className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
<Alert
variant="warning"
className="mt-3"
data-testid="guardrails-instance-disabled"
>
{t('agents.form.guardrails.instanceDisabled')}
</p>
<TriangleAlert aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.instanceDisabled')}
</AlertDescription>
</Alert>
)}
{floorControls.size > 0 && (
<p className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
{t('agents.form.guardrails.floorNotice', {
count: floorControls.size,
})}
</p>
<Alert variant="info" role="status" className="mt-3">
<Info aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.floorNotice', {
count: floorControls.size,
})}
</AlertDescription>
</Alert>
)}
<div className="mt-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">
{t('agents.form.guardrails.enable')}
</h3>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.guardrails.enableDescription')}
</p>
</div>
<SettingRow
className="mt-4"
label={t('agents.form.guardrails.enable')}
description={t('agents.form.guardrails.enableDescription')}
htmlFor={enabledId}
>
<Switch
className="shrink-0"
id={enabledId}
checked={config.enabled}
disabled={disabled}
data-testid="guardrails-enabled"
onCheckedChange={(checked) => patch({ enabled: checked })}
/>
</div>
</SettingRow>
{config.enabled && (
<>
<div className="mt-5">
<label className="mb-2 block text-sm font-medium">
{t('agents.form.guardrails.mode')}
</label>
<Select
value={config.mode}
onValueChange={(mode) =>
patch({ mode: mode as GuardrailsConfig['mode'] })
<FormField
label={t('agents.form.guardrails.mode')}
hint={
config.mode === 'monitor_only'
? t('agents.form.guardrails.monitorHint')
: undefined
}
disabled={disabled}
>
<SelectTrigger
className="w-full rounded-3xl px-5 py-3 text-sm"
size="lg"
data-testid="guardrails-mode"
<Select
value={config.mode}
onValueChange={(mode) =>
patch({ mode: mode as GuardrailsConfig['mode'] })
}
disabled={disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map((mode) => (
<SelectItem key={mode} value={mode}>
{t(MODE_KEYS[mode] ?? mode)}
</SelectItem>
))}
</SelectContent>
</Select>
{config.mode === 'monitor_only' && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
{t('agents.form.guardrails.monitorHint')}
</p>
)}
<SelectTrigger
className="w-full"
size="field"
shape="pill"
data-testid="guardrails-mode"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map(
(mode) => (
<SelectItem key={mode} value={mode}>
{t(MODE_KEYS[mode] ?? mode)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormField>
</div>
<div className="mt-5">
@@ -363,9 +397,11 @@ export default function GuardrailsSection({
/>
))}
{orphanControls.map((control) => (
<div
<Card
key={key(control.check, control.stage)}
className="border-destructive/40 flex items-center justify-between gap-3 rounded-xl border px-4 py-3"
tone="destructive"
padding="sm"
className="flex-row items-center justify-between"
data-testid={`guardrail-orphan-${control.check}`}
>
<p className="text-xs">
@@ -375,61 +411,56 @@ export default function GuardrailsSection({
</p>
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-destructive-on-accent"
size="xs"
disabled={disabled}
className="text-destructive h-auto px-2 py-1 text-xs"
onClick={() =>
removeControl(control.check, control.stage)
}
>
{t('agents.form.guardrails.remove')}
</Button>
</div>
</Card>
))}
</div>
</div>
<div className="mt-6">
<label className="mb-2 block text-sm font-medium">
{t('agents.form.guardrails.blockMessage')}
</label>
<FormField
className="mt-6"
label={t('agents.form.guardrails.blockMessage')}
hint={t('agents.form.guardrails.blockMessageDescription')}
disabled={disabled}
>
<Input
type="text"
value={config.block_message}
maxLength={500}
disabled={disabled}
data-testid="guardrails-block-message"
onChange={(e) => patch({ block_message: e.target.value })}
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
shape="pill"
/>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.guardrails.blockMessageDescription')}
</p>
</div>
</FormField>
<div className="mt-6 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">
{t('agents.form.guardrails.failOpen')}
</h3>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.guardrails.failOpenDescription')}
</p>
</div>
<SettingRow
className="mt-6"
label={t('agents.form.guardrails.failOpen')}
description={t('agents.form.guardrails.failOpenDescription')}
htmlFor={failOpenId}
>
<Switch
className="shrink-0"
id={failOpenId}
checked={config.fail_open}
disabled={disabled}
data-testid="guardrails-fail-open"
onCheckedChange={(checked) => patch({ fail_open: checked })}
/>
</div>
</SettingRow>
<div className="mt-4">
<label className="mb-2 block text-sm font-medium">
{t('agents.form.guardrails.timeout')}
</label>
<FormField
className="mt-5"
label={t('agents.form.guardrails.timeout')}
disabled={disabled}
>
<NumberField
value={config.timeout_ms}
min={100}
@@ -440,7 +471,7 @@ export default function GuardrailsSection({
testId="guardrails-timeout"
onCommit={(timeout_ms) => patch({ timeout_ms })}
/>
</div>
</FormField>
</>
)}
</div>
@@ -494,7 +525,8 @@ function NumberField({
setDraft(String(next));
onCommit(next);
}}
className="bg-card h-auto rounded-3xl px-5 py-3 text-sm md:text-sm"
variant="filled"
shape="pill"
/>
);
}
@@ -565,32 +597,27 @@ function CheckCard({
);
return (
<div
className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700"
data-testid={`guardrail-check-${info.name}`}
>
<Card padding="sm" data-testid={`guardrail-check-${info.name}`}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-medium">{info.label}</h4>
<span
className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
<SectionHeader as="h4" size="xs" title={info.label} />
<Badge
variant="neutral"
title={t('agents.form.guardrails.latencyHint')}
data-testid={`guardrail-latency-${info.name}`}
>
{latencyLabel(info.latency_hint_ms)}
</span>
</Badge>
{unavailable && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<Badge variant="warning">
{t('agents.form.guardrails.notConfigured')}
</span>
</Badge>
)}
</div>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{info.description}
</p>
<p className="text-muted-foreground mt-1 text-xs">{info.description}</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2">
{info.stages.map((stage) => {
const control = controlFor(info.name, stage);
const floorAction = floorControls.get(key(info.name, stage));
@@ -602,40 +629,52 @@ function CheckCard({
// out a credential strands a control that can never be cleared.
const canToggle =
!disabled && !locked && (Boolean(control) || !unavailable);
return (
<button
const chip = (
<Button
key={stage}
type="button"
variant={on ? 'secondary' : 'ghost-muted'}
size="xs"
shape="pill"
disabled={!canToggle}
data-testid={`guardrail-stage-${info.name}-${stage}`}
onClick={() => toggleControl(info, stage, !control)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
on
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
} ${!canToggle ? 'cursor-not-allowed opacity-60' : ''}`}
title={
locked ? t('agents.form.guardrails.lockedByFloor') : undefined
}
>
{t(STAGE_KEYS[stage] ?? stage)}
{locked ? ' 🔒' : ''}
</button>
</Button>
);
// A disabled Button takes no pointer events, so the locked chip's
// hint lives on a wrapper that can still be hovered.
return locked ? (
<span
key={stage}
className="inline-flex"
title={t('agents.form.guardrails.lockedByFloor')}
>
{chip}
</span>
) : (
chip
);
})}
</div>
{floorForCheck.map(([k, action]) => (
<div
<Alert
key={`floor-${k}`}
className="mt-3 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
variant="info"
role="note"
data-testid={`guardrail-floor-${k}`}
>
{t('agents.form.guardrails.floorControl', {
stage: t(STAGE_KEYS[k.split(':')[1] as GuardrailStage] ?? ''),
action: t(ACTION_KEYS[action] ?? action),
})}
</div>
<Info aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.floorControl', {
stage: t(STAGE_KEYS[k.split(':')[1] as GuardrailStage] ?? ''),
action: t(ACTION_KEYS[action] ?? action),
})}
</AlertDescription>
</Alert>
))}
{active.map((control) => {
@@ -644,11 +683,12 @@ function CheckCard({
return (
<div
key={panelKey}
className={`mt-3 rounded-lg px-3 py-2 ${
className={cn(
'rounded-lg px-3 py-2',
needsSetup
? 'border-destructive/50 bg-destructive/5 border'
: 'bg-gray-50 dark:bg-gray-900/40'
}`}
? 'border-destructive/50 bg-destructive/10 border'
: 'bg-muted',
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium">
@@ -665,7 +705,8 @@ function CheckCard({
}
>
<SelectTrigger
className="h-auto rounded-full px-3 py-1 text-xs"
size="sm"
shape="pill"
data-testid={`guardrail-action-${control.check}-${control.stage}`}
>
<SelectValue />
@@ -687,8 +728,7 @@ function CheckCard({
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
size="xs"
data-testid={`guardrail-configure-${control.check}-${control.stage}`}
onClick={() =>
setOpenSettings(
@@ -701,10 +741,9 @@ function CheckCard({
)}
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-destructive"
size="xs"
disabled={disabled}
className="text-destructive h-auto px-2 py-1 text-xs"
data-testid={`guardrail-remove-${control.check}-${control.stage}`}
onClick={() => removeControl(control.check, control.stage)}
>
@@ -714,6 +753,7 @@ function CheckCard({
</div>
{needsSetup && (
<p
role="alert"
className="text-destructive mt-1 text-xs"
data-testid={`guardrail-needs-setup-${control.check}-${control.stage}`}
>
@@ -733,7 +773,7 @@ function CheckCard({
</div>
);
})}
</div>
</Card>
);
}
@@ -757,11 +797,10 @@ function CheckSettings({
const set = (next: Record<string, any>) => onChange({ ...s, ...next });
const listField = (fieldKey: string, label: string, placeholder: string) => (
<div className="mt-2">
<label className="mb-1 block text-xs font-medium">{label}</label>
<textarea
<FormField label={label} labelSurface="muted" disabled={disabled}>
<Textarea
rows={2}
disabled={disabled}
size="sm"
data-testid={`guardrail-setting-${control.check}-${fieldKey}`}
value={(s[fieldKey] ?? []).join('\n')}
placeholder={placeholder}
@@ -773,21 +812,24 @@ function CheckSettings({
.filter(Boolean),
})
}
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
variant="filled"
/>
</div>
</FormField>
);
return (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<div className="border-border mt-3 flex flex-col gap-5 border-t pt-5">
{control.check === 'pii' && (
<div className="flex flex-wrap gap-2">
{(catalog?.pii_entities ?? []).map((entity) => {
const on = (s.entities ?? []).includes(entity);
return (
<button
<Button
key={entity}
type="button"
variant={on ? 'secondary' : 'ghost-muted'}
size="xs"
shape="pill"
disabled={disabled}
data-testid={`guardrail-pii-${entity}`}
onClick={() =>
@@ -797,18 +839,13 @@ function CheckSettings({
: [...(s.entities ?? []), entity],
})
}
className={`rounded-full border px-2 py-0.5 text-[11px] ${
on
? 'border-violets-are-blue bg-violets-are-blue/10 text-violets-are-blue'
: 'border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400'
}`}
>
{entity}
</button>
</Button>
);
})}
{(s.entities ?? []).length === 0 && (
<p className="text-destructive text-xs">
<p role="alert" className="text-destructive text-xs">
{t('agents.form.guardrails.pickAtLeastOne')}
</p>
)}
@@ -838,27 +875,29 @@ function CheckSettings({
)}
{control.check === 'policy' && (
<div className="mt-2">
<label className="mb-1 block text-xs font-medium">
{t('agents.form.guardrails.policyText')}
</label>
<textarea
<FormField
label={t('agents.form.guardrails.policyText')}
labelSurface="muted"
disabled={disabled}
>
<Textarea
rows={4}
size="sm"
value={s.policy ?? ''}
disabled={disabled}
data-testid="guardrail-policy-text"
onChange={(e) => set({ policy: e.target.value })}
className="bg-card w-full rounded-xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700"
variant="filled"
/>
</div>
</FormField>
)}
{control.check === 'groundedness' && (
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium">
{t('agents.form.guardrails.minOverlap')}
</label>
<div className="grid grid-cols-2 gap-2">
<FormField
label={t('agents.form.guardrails.minOverlap')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.min_overlap ?? 0.3}
min={0}
@@ -868,11 +907,12 @@ function CheckSettings({
disabled={disabled}
onCommit={(min_overlap) => set({ min_overlap })}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">
{t('agents.form.guardrails.minWords')}
</label>
</FormField>
<FormField
label={t('agents.form.guardrails.minWords')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.min_words ?? 25}
min={1}
@@ -882,15 +922,17 @@ function CheckSettings({
disabled={disabled}
onCommit={(min_words) => set({ min_words })}
/>
</div>
</FormField>
</div>
)}
{control.check === 'policy' && (
<div className="mt-2">
<label className="mb-1 block text-xs font-medium">
{t('agents.form.guardrails.confidence')}
</label>
<FormField
label={t('agents.form.guardrails.confidence')}
hint={t('agents.form.guardrails.confidenceHint')}
labelSurface="muted"
disabled={disabled}
>
<NumberField
value={s.confidence_threshold ?? 0.7}
min={0}
@@ -900,10 +942,7 @@ function CheckSettings({
disabled={disabled}
onCommit={(confidence_threshold) => set({ confidence_threshold })}
/>
<p className="mt-1 text-[11px] text-gray-500">
{t('agents.form.guardrails.confidenceHint')}
</p>
</div>
</FormField>
)}
</div>
);
@@ -0,0 +1,136 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '../../locale/i18n';
import type { ScheduleRun } from '../types/schedule';
import RunDetailDrawer from './RunDetailDrawer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const run: ScheduleRun = {
id: 'r1',
schedule_id: 's1',
user_id: 'u1',
agent_id: 'a1',
status: 'failed',
scheduled_for: '2026-09-25T09:00:00Z',
trigger_source: 'cron',
started_at: '2026-09-25T09:00:00Z',
finished_at: '2026-09-25T09:01:00Z',
output: null,
output_truncated: false,
error: 'brave_search returned 429',
error_type: null,
prompt_tokens: 1120,
generated_tokens: 84,
created_at: '2026-09-25T09:00:00Z',
updated_at: '2026-09-25T09:01:00Z',
};
const content = () =>
document.querySelector<HTMLElement>('[data-slot="sheet-content"]');
describe('RunDetailDrawer', () => {
it('opens as a right Sheet with the standard X, not a hand-rolled aside', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const panel = content()!;
expect(panel).not.toBeNull();
expect(panel.dataset.side).toBe('right');
expect(panel.getAttribute('role')).toBe('dialog');
expect(panel.className.split(' ')).toEqual(
expect.arrayContaining(['bg-background', 'sm:max-w-xl']),
);
expect(panel.querySelector('[data-slot="sheet-title"]')!.textContent).toBe(
'Run details',
);
expect(panel.querySelector('[aria-label="Close"]')).not.toBeNull();
expect(document.querySelector('aside')).toBeNull();
});
it('labels come from the locale, trigger and status included', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const text = content()!.textContent!;
expect(text).toContain('Scheduled for');
expect(text).toContain('1120 prompt · 84 generated');
expect(text).toContain('Scheduled');
expect(text).toContain('Failed');
});
// L9: a code block on the sheet is a filled Card around the mono recipe.
it('puts the error on a filled Card so it shows on the sheet', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const pre = content()!.querySelector('pre')!;
const card = pre.parentElement!;
expect(card.getAttribute('data-slot')).toBe('card');
expect(card.getAttribute('data-variant')).toBe('filled');
expect(card.className.split(' ')).toContain('bg-muted');
expect(card.className.split(' ')).not.toContain('bg-background');
expect(pre.className.split(' ')).toEqual(
expect.arrayContaining([
'font-mono',
'text-xs',
'whitespace-pre-wrap',
'wrap-break-word',
]),
);
});
// Decisions 63a / 63b: the title is the default SheetTitle; the Error
// heading is a destructive xs sub-heading.
it('uses the default sheet title and an xs destructive error heading', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const title = content()!.querySelector('[data-slot="sheet-title"]')!;
expect(title.className.split(' ')).toEqual(
expect.arrayContaining(['text-xl', 'leading-tight', 'font-semibold']),
);
expect(title.className).not.toContain('text-lg');
const error = content()!.querySelector('h3')!;
expect(error.textContent).toBe('Error');
expect(error.className.split(' ')).toEqual(
expect.arrayContaining(['text-destructive', 'text-sm', 'font-semibold']),
);
});
it('closes from the X', async () => {
const onClose = vi.fn();
await act(async () =>
root.render(<RunDetailDrawer run={run} onClose={onClose} />),
);
await act(async () =>
content()!
.querySelector<HTMLButtonElement>('[aria-label="Close"]')!
.click(),
);
expect(onClose).toHaveBeenCalled();
});
it('renders nothing without a run', async () => {
await act(async () =>
root.render(<RunDetailDrawer run={null} onClose={vi.fn()} />),
);
expect(content()).toBeNull();
});
});
@@ -1,6 +1,12 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/button';
import { Card } from '@/components/ui/card';
import {
DescriptionItem,
DescriptionList,
} from '@/components/ui/description-list';
import { SectionHeader } from '@/components/ui/section-header';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { formatDateTime } from '../../utils/dateTimeUtils';
import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
@@ -14,89 +20,104 @@ const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
/** Side drawer with a single run's output / error (terminal-state only). */
/** Side sheet with a single run's output / error (terminal-state only). */
export default function RunDetailDrawer({
run,
onClose,
}: RunDetailDrawerProps) {
useEffect(() => {
if (!run) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [run, onClose]);
const { t } = useTranslation();
if (!run) return null;
return (
<>
<div
className="fixed inset-0 z-20 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<aside
className="border-border bg-card fixed top-0 right-0 z-30 flex h-full w-full max-w-xl flex-col border-l p-6 shadow-lg"
role="dialog"
aria-label="Schedule run details"
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="sm:max-w-xl"
aria-describedby={undefined}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Run details</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="text-muted-foreground"
>
Close
</Button>
{/* The sheet owns no padding of its own; the body sets it. */}
<div className="flex min-h-0 flex-1 flex-col gap-4 p-6">
<SheetTitle>{t('agents.schedules.runDetails.title')}</SheetTitle>
<DescriptionList size="sm">
<DescriptionItem label={t('agents.schedules.runDetails.status')}>
<ScheduleStatusBadge status={run.status} />
</DescriptionItem>
<DescriptionItem
label={t('agents.schedules.runDetails.scheduledFor')}
>
{formatTimestamp(run.scheduled_for)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.started')}>
{formatTimestamp(run.started_at)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.finished')}>
{formatTimestamp(run.finished_at)}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.tokens')}>
{t('agents.schedules.runDetails.tokensValue', {
prompt: run.prompt_tokens,
generated: run.generated_tokens,
})}
</DescriptionItem>
<DescriptionItem label={t('agents.schedules.runDetails.trigger')}>
{t(`agents.schedules.trigger.${run.trigger_source}`, {
defaultValue: run.trigger_source,
})}
</DescriptionItem>
</DescriptionList>
{run.error && (
<section className="flex flex-col gap-1">
<SectionHeader
as="h3"
size="xs"
tone="destructive"
title={
run.error_type
? t('agents.schedules.runDetails.errorWithType', {
type: run.error_type,
})
: t('agents.schedules.runDetails.error')
}
/>
<Card
variant="filled"
padding="sm"
className="max-h-48 overflow-y-auto"
>
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.error}
</pre>
</Card>
</section>
)}
{run.output && (
<section className="flex min-h-0 flex-1 flex-col gap-1">
<SectionHeader
as="h3"
size="xs"
title={
<>
{t('agents.schedules.runDetails.output')}
{run.output_truncated && (
<span className="text-muted-foreground ml-1 text-xs">
{t('agents.schedules.runDetails.truncated')}
</span>
)}
</>
}
/>
<Card
variant="filled"
padding="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.output}
</pre>
</Card>
</section>
)}
</div>
<dl className="mb-4 grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Status</dt>
<dd>
<ScheduleStatusBadge status={run.status} />
</dd>
<dt className="text-muted-foreground">Scheduled for</dt>
<dd>{formatTimestamp(run.scheduled_for)}</dd>
<dt className="text-muted-foreground">Started</dt>
<dd>{formatTimestamp(run.started_at)}</dd>
<dt className="text-muted-foreground">Finished</dt>
<dd>{formatTimestamp(run.finished_at)}</dd>
<dt className="text-muted-foreground">Tokens</dt>
<dd>
{run.prompt_tokens} prompt · {run.generated_tokens} generated
</dd>
<dt className="text-muted-foreground">Trigger</dt>
<dd>{run.trigger_source}</dd>
</dl>
{run.error && (
<section className="mb-4">
<h3 className="text-destructive text-sm font-semibold">
Error{run.error_type ? ` (${run.error_type})` : ''}
</h3>
<pre className="bg-background mt-1 max-h-48 overflow-auto rounded-md p-3 font-mono text-xs">
{run.error}
</pre>
</section>
)}
{run.output && (
<section className="flex-1 overflow-hidden">
<h3 className="text-sm font-semibold">
Output
{run.output_truncated && (
<span className="text-muted-foreground ml-1 text-xs">
(truncated)
</span>
)}
</h3>
<pre className="bg-background mt-1 h-full overflow-auto rounded-md p-3 font-mono text-xs whitespace-pre-wrap">
{run.output}
</pre>
</section>
)}
</aside>
</>
</SheetContent>
</Sheet>
);
}
+52 -26
View File
@@ -1,7 +1,16 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../../components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
@@ -9,6 +18,10 @@ import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
// The column heads are a label row, so they take the eyebrow recipe.
const HEADER_CELL =
'text-muted-foreground text-xs font-semibold tracking-wider uppercase';
export type RunLogProps = {
scheduleId: string;
onSelect?: (run: ScheduleRun) => void;
@@ -20,6 +33,7 @@ const formatTimestamp = (value?: string | null): string => {
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const runs = useSelector((state: RootState) =>
@@ -34,27 +48,35 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
if (runs.length === 0) {
return (
<p className="text-muted-foreground py-3 text-sm">
No runs recorded for this schedule yet.
{t('agents.schedules.runLog.empty')}
</p>
);
}
return (
<table className="w-full text-left text-sm">
<thead className="text-muted-foreground text-xs uppercase">
<tr>
<th className="py-2">When</th>
<th className="py-2">Status</th>
<th className="py-2">Tokens</th>
<th className="py-2">Trigger</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
<Table minWidth="min-w-0">
<TableHead>
<TableRow>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runLog.when')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.status')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.tokens')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.trigger')}
</TableHeader>
<TableHeader className={HEADER_CELL}></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{runs.map((run) => (
<tr key={run.id} className="border-border border-t">
<td className="py-2">{formatTimestamp(run.scheduled_for)}</td>
<td className="py-2">
<TableRow key={run.id}>
<TableCell>{formatTimestamp(run.scheduled_for)}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<ScheduleStatusBadge status={run.status} />
{run.error_type && (
@@ -63,25 +85,29 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
</span>
)}
</div>
</td>
<td className="py-2">{run.prompt_tokens + run.generated_tokens}</td>
<td className="py-2">{run.trigger_source}</td>
<td className="py-2">
</TableCell>
<TableCell>{run.prompt_tokens + run.generated_tokens}</TableCell>
<TableCell>
{t(`agents.schedules.trigger.${run.trigger_source}`, {
defaultValue: run.trigger_source,
})}
</TableCell>
<TableCell>
{onSelect && (
<Button
type="button"
variant="link"
size="sm"
size="xs"
onClick={() => onSelect(run)}
className="h-auto p-0 text-xs underline"
className="-mx-2 -my-1"
>
Details
{t('agents.schedules.runLog.details')}
</Button>
)}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
);
}
@@ -1,9 +1,13 @@
import { CalendarIcon } from 'lucide-react';
import { CalendarIcon, CircleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
@@ -16,8 +20,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { TimePicker } from '@/components/ui/time-picker';
import { cn } from '@/lib/utils';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Modal } from '../../components/ui/modal';
import { formatDateOnly } from '../../utils/dateTimeUtils';
@@ -138,14 +143,24 @@ export default function ScheduleFormModal({
// the engine's supported list (e.g. an exotic tz saved on the schedule).
return list.includes(timezone) ? list : [timezone, ...list];
}, [timezone]);
const [error, setError] = useState<string | null>(null);
// A missing instruction is the Instructions field's error; a run time in
// the past spans the date, time and timezone, so it is an Alert.
const [instructionError, setInstructionError] = useState<string | null>(null);
const [runAtError, setRunAtError] = useState<string | null>(null);
const setError = (field: 'instruction' | 'runAt', message: string) => {
setInstructionError(field === 'instruction' ? message : null);
setRunAtError(field === 'runAt' ? message : null);
};
const setFrequency = (frequency: ScheduleFrequency) =>
setValues((current) => ({ ...current, frequency }));
const submit = async () => {
if (!instruction.trim()) {
setError(t('agents.schedules.modal.errors.instructionRequired'));
setError(
'instruction',
t('agents.schedules.modal.errors.instructionRequired'),
);
return;
}
const payload: ScheduleCreatePayload = {
@@ -159,11 +174,11 @@ export default function ScheduleFormModal({
try {
runAt = buildRunAtUtc(values.date, values.time, timezone);
} catch {
setError(t('agents.schedules.modal.errors.runAtInPast'));
setError('runAt', t('agents.schedules.modal.errors.runAtInPast'));
return;
}
if (new Date(runAt).getTime() <= Date.now()) {
setError(t('agents.schedules.modal.errors.runAtInPast'));
setError('runAt', t('agents.schedules.modal.errors.runAtInPast'));
return;
}
payload.trigger_type = 'once';
@@ -171,13 +186,17 @@ export default function ScheduleFormModal({
} else {
const cron = buildCron(values.frequency, values);
if (!cron) {
setError(t('agents.schedules.modal.errors.instructionRequired'));
setError(
'instruction',
t('agents.schedules.modal.errors.instructionRequired'),
);
return;
}
payload.trigger_type = 'recurring';
payload.cron = cron;
}
setError(null);
setInstructionError(null);
setRunAtError(null);
await onSubmit(payload);
};
@@ -195,17 +214,32 @@ export default function ScheduleFormModal({
}
size="md"
mobileVariant="sheet"
className="w-[min(560px,92vw)] sm:p-6"
contentClassName="max-h-[80vh]"
footer={
<Button
type="button"
size="lg"
shape="pill"
loading={submitting}
onClick={submit}
>
{isEdit
? t('agents.schedules.modal.save')
: t('agents.schedules.modal.create')}
</Button>
}
>
<div className="flex flex-col gap-5">
<div className="flex items-start gap-3 pr-6">
<input
<Input
type="text"
variant="bare"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('agents.schedules.modal.namePlaceholder')}
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-xl font-semibold outline-none"
/* eslint-disable-next-line shadcn/no-restyle -- the schedule's
name is the dialog's editable title, so the bare field keeps
title type (20px semibold); DESIGN.md Approved exceptions. */
className="w-full text-xl font-semibold"
aria-label={t('agents.schedules.modal.namePlaceholder')}
/>
</div>
@@ -220,6 +254,7 @@ export default function ScheduleFormModal({
monthly: t('agents.schedules.modal.frequency.monthly'),
yearly: t('agents.schedules.modal.frequency.yearly'),
}}
ariaLabel={t('agents.schedules.modal.frequencyLabel')}
/>
<OnPicker
@@ -231,10 +266,14 @@ export default function ScheduleFormModal({
on: t('agents.schedules.modal.on'),
at: t('agents.schedules.modal.at'),
pickDate: t('agents.schedules.modal.pickDate'),
days: t('agents.schedules.modal.dayOfWeek'),
}}
/>
<div className="border-border flex flex-wrap items-center justify-between gap-2 rounded-md border p-3">
<Card
padding="sm"
className="flex-row flex-wrap items-center justify-between gap-2"
>
<span className="text-foreground text-sm font-medium">
{t('agents.schedules.modal.timezone')}
</span>
@@ -251,37 +290,26 @@ export default function ScheduleFormModal({
ariaLabel={t('agents.schedules.modal.timezone')}
/>
</div>
</div>
</Card>
<label className="flex flex-col gap-2">
<span className="text-foreground text-sm font-medium">
{t('agents.schedules.modal.instructionsLabel')}
</span>
<textarea
<FormField
label={t('agents.schedules.modal.instructionsLabel')}
error={instructionError}
>
<Textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder={t('agents.schedules.modal.instructionsPlaceholder')}
rows={5}
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
/>
</label>
</FormField>
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end">
<Button
type="button"
disabled={submitting}
onClick={submit}
className="rounded-3xl px-5"
>
{submitting
? '…'
: isEdit
? t('agents.schedules.modal.save')
: t('agents.schedules.modal.create')}
</Button>
</div>
{runAtError && (
<Alert variant="destructive">
<CircleAlert aria-hidden="true" className="size-4" />
<AlertDescription>{runAtError}</AlertDescription>
</Alert>
)}
</div>
</Modal>
);
@@ -291,32 +319,32 @@ type FrequencyTabsProps = {
frequency: ScheduleFrequency;
onChange: (f: ScheduleFrequency) => void;
labels: Record<ScheduleFrequency, string>;
ariaLabel: string;
};
function FrequencyTabs({ frequency, onChange, labels }: FrequencyTabsProps) {
function FrequencyTabs({
frequency,
onChange,
labels,
ariaLabel,
}: FrequencyTabsProps) {
return (
<div className="bg-muted/60 dark:bg-muted/40 inline-flex w-full gap-1 rounded-full p-1">
{FREQUENCIES.map((f) => {
const active = f === frequency;
return (
<Button
key={f}
type="button"
variant="ghost"
size="sm"
onClick={() => onChange(f)}
className={cn(
'h-auto flex-1 rounded-full px-3 py-1.5 text-xs font-medium',
active
? 'bg-card text-foreground hover:bg-card shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-transparent',
)}
aria-pressed={active}
>
// The muted track is a plain wrapper: ToggleGroup takes layout only.
<div className="bg-muted w-full rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={frequency}
onValueChange={(f) => f && onChange(f as ScheduleFrequency)}
aria-label={ariaLabel}
className="flex-nowrap"
>
{FREQUENCIES.map((f) => (
<ToggleGroupItem key={f} value={f} className="flex-1">
{labels[f]}
</Button>
);
})}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
@@ -326,7 +354,7 @@ type OnPickerProps = {
onChange: (next: ScheduleFormValues) => void;
tDay: (key: string) => string;
tMonth: (key: string) => string;
labels: { on: string; at: string; pickDate: string };
labels: { on: string; at: string; pickDate: string; days: string };
};
function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
@@ -334,7 +362,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
onChange({ ...values, ...patch });
return (
<div className="border-border flex flex-col gap-3 rounded-md border p-3">
<Card padding="sm">
{values.frequency === 'once' && (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-foreground text-sm font-medium">
@@ -370,24 +398,19 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
{values.frequency === 'weekly' && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{DAY_OPTIONS.map((d) => {
const active = d.value === values.dayOfWeek;
return (
<Button
key={d.key}
type="button"
size="sm"
variant={active ? 'default' : 'outline'}
onClick={() => set({ dayOfWeek: d.value })}
className="rounded-full"
aria-pressed={active}
>
{tDay(d.key)}
</Button>
);
})}
</div>
{/* A weekly schedule runs on one day: a radio group. */}
<ToggleGroup
type="single"
value={String(values.dayOfWeek)}
onValueChange={(day) => day && set({ dayOfWeek: Number(day) })}
aria-label={labels.days}
>
{DAY_OPTIONS.map((d) => (
<ToggleGroupItem key={d.key} value={String(d.value)}>
{tDay(d.key)}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center justify-between gap-2">
<span className="text-foreground text-sm font-medium">
{labels.at}
@@ -446,7 +469,7 @@ function OnPicker({ values, onChange, tDay, tMonth, labels }: OnPickerProps) {
</div>
</div>
)}
</div>
</Card>
);
}
@@ -464,20 +487,16 @@ function DatePicker({ value, onChange, placeholder }: DatePickerProps) {
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
variant="combobox"
aria-label={placeholder}
className={cn(
'h-9 justify-start gap-2 px-3 font-normal',
!value && 'text-muted-foreground',
)}
data-placeholder={value ? undefined : ''}
className="justify-start"
>
<CalendarIcon className="size-4 opacity-70" />
<CalendarIcon className="opacity-70" />
{value ? formatDateLabel(value) : placeholder}
</Button>
</PopoverTrigger>
{/* z-200 keeps the popover above Modal (z-50); matches SelectContent. */}
<PopoverContent className="z-200 w-auto p-0" align="start">
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
@@ -517,7 +536,7 @@ function DayOfMonthSelect({
}: DayOfMonthSelectProps) {
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger size="sm" aria-label={ariaLabel} className="h-9 w-[5rem]">
<SelectTrigger aria-label={ariaLabel} className="w-[5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -541,11 +560,7 @@ type MonthSelectProps = {
function MonthSelect({ value, onChange, tMonth, ariaLabel }: MonthSelectProps) {
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger
size="sm"
aria-label={ariaLabel}
className="h-9 w-[6.5rem]"
>
<SelectTrigger aria-label={ariaLabel} className="w-[6.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1,6 +1,21 @@
import { describe, expect, it } from 'vitest';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { extractToolError } from './SchedulerToolCallCard';
import '../../locale/i18n';
import SchedulerToolCallCard, {
extractToolError,
type SchedulerToolCallCardProps,
} from './SchedulerToolCallCard';
vi.mock('react-redux', () => ({
useDispatch: () => vi.fn(),
useSelector: () => null,
}));
const render = (props: SchedulerToolCallCardProps) =>
renderToStaticMarkup(createElement(SchedulerToolCallCard, props));
// Regression for the iter-6 issue where ``cancel_scheduled_task`` returning
// a plain ``"Error: …"`` string still rendered "Scheduled task cancelled."
@@ -35,3 +50,80 @@ describe('extractToolError', () => {
expect(extractToolError(null)).toBeNull();
});
});
describe('SchedulerToolCallCard', () => {
const scheduled = {
task_id: 't1',
resolved_run_at: '2026-09-27T09:00:00Z',
instruction: 'Send the weekly report',
};
it('renders a scheduled task on a Card with an icon, time and cancel pill', () => {
const html = render({ actionName: 'schedule_task', result: scheduled });
expect(html).toContain('data-slot="card"');
expect(html).not.toContain('rounded-2xl border p-4');
expect(html).toContain('lucide-calendar-clock');
expect(html).toContain('>Scheduled task<');
expect(html).not.toContain('⏰');
expect(html).toContain('text-muted-foreground text-xs');
// Instruction: muted, indented under the title, no quotes or italic.
expect(html).toContain('Send the weekly report');
expect(html).not.toContain('“');
expect(html).not.toContain('italic');
expect(html).toContain('pl-7');
expect(html).toContain('data-variant="destructive-outline"');
expect(html).toContain('>Cancel<');
expect(html).not.toContain('mt-2');
});
it('shows a spinner, not the calendar icon, while pending', () => {
const html = render({
actionName: 'schedule_task',
result: scheduled,
status: 'pending',
});
expect(html).toContain('data-slot="spinner"');
expect(html).not.toContain('lucide-calendar-clock');
expect(html).toContain('Scheduling…');
});
it('puts the scheduling error on its own line under a title-only heading', () => {
const html = render({
actionName: 'schedule_task',
result: { error: 'bad time' },
});
expect(html).toContain('lucide-circle-alert');
expect(html).toContain('>Scheduling failed<');
expect(html).toContain('>bad time<');
});
it('puts the cancel error on its own line under a title-only heading', () => {
const html = render({
actionName: 'cancel_scheduled_task',
result: 'Error: not found',
});
expect(html).toContain('lucide-circle-alert');
expect(html).toContain('>Cancel failed<');
expect(html).toContain('>not found<');
});
it('renders a successful cancel with the cancelled calendar icon', () => {
const html = render({
actionName: 'cancel_scheduled_task',
result: { task_id: 't1', status: 'cancelled' },
});
expect(html).toContain('lucide-calendar-x-2');
expect(html).toContain('Scheduled task cancelled.');
});
it('lists pending tasks on a Card', () => {
const html = render({
actionName: 'list_scheduled_tasks',
result: { tasks: [scheduled] },
});
expect(html).toContain('data-slot="card"');
expect(html).toContain('1 pending scheduled task');
expect(html).toContain('Send the weekly report');
expect(html).not.toContain('mt-2');
});
});
@@ -1,11 +1,17 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { CalendarClock, CalendarX2, CircleAlert } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import { Spinner } from '../../components/ui/spinner';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { deleteSchedule, loadSchedulesForAgent } from './schedulesSlice';
import ScheduleStatusBadge from './StatusBadge';
export type SchedulerToolCallCardProps = {
/** Outcome JSON the scheduler tool returned (action result). */
@@ -53,6 +59,7 @@ export default function SchedulerToolCallCard({
status,
agentId,
}: SchedulerToolCallCardProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const [cancelled, setCancelled] = useState<boolean>(false);
@@ -93,17 +100,30 @@ export default function SchedulerToolCallCard({
const cancelError = extractToolError(result);
if (cancelError) {
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="text-destructive font-semibold">
Cancel failed: {cancelError}
</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CircleAlert className="text-destructive mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-destructive font-semibold">
{t('agents.schedules.toolCard.cancelFailed')}
</p>
<p className="text-muted-foreground">{cancelError}</p>
</div>
</div>
</Card>
);
}
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="font-semibold">Scheduled task cancelled.</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CalendarX2 className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{t('agents.schedules.toolCard.taskCancelled')}
</p>
</div>
</div>
</Card>
);
}
@@ -112,19 +132,28 @@ export default function SchedulerToolCallCard({
? (parsed?.tasks as Array<Record<string, unknown>>)
: [];
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="font-semibold">
{tasks.length} pending scheduled task{tasks.length === 1 ? '' : 's'}
</p>
<ul className="mt-2 flex flex-col gap-1">
{tasks.map((task) => (
<li key={String(task.task_id)}>
{formatTimestamp(task.resolved_run_at as string)} —{' '}
{String(task.instruction || task.name || task.task_id)}
</li>
))}
</ul>
</div>
<Card>
<div className="flex items-start gap-3">
<CalendarClock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{t('agents.schedules.toolCard.pendingCount', {
count: tasks.length,
})}
</p>
</div>
</div>
{tasks.length > 0 && (
<ul className="flex flex-col gap-1 pl-7">
{tasks.map((task) => (
<li key={String(task.task_id)}>
{formatTimestamp(task.resolved_run_at as string)} —{' '}
{String(task.instruction || task.name || task.task_id)}
</li>
))}
</ul>
)}
</Card>
);
}
@@ -133,45 +162,59 @@ export default function SchedulerToolCallCard({
const schedulingError = error || extractToolError(result);
if (schedulingError) {
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<p className="text-destructive font-semibold">
Scheduling failed: {schedulingError}
</p>
</div>
<Card>
<div className="flex items-start gap-3">
<CircleAlert className="text-destructive mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-destructive font-semibold">
{t('agents.schedules.toolCard.schedulingFailed')}
</p>
<p className="text-muted-foreground">{schedulingError}</p>
</div>
</div>
</Card>
);
}
const iconClass = 'text-muted-foreground mt-0.5 size-4 shrink-0';
return (
<div className="border-border bg-card rounded-2xl border p-4 text-sm">
<div className="flex items-center justify-between">
<p className="font-semibold">
{status === 'pending' ? '⏰ Scheduling…' : '⏰ Scheduled task'}
</p>
{runAt && (
<span className="text-muted-foreground text-xs">
{formatTimestamp(runAt)}
</span>
<Card>
<div className="flex items-start gap-3">
{status === 'pending' ? (
<Spinner size="xs" className="text-muted-foreground mt-0.5" />
) : cancelled ? (
<CalendarX2 className={iconClass} />
) : (
<CalendarClock className={iconClass} />
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="font-semibold">
{status === 'pending'
? t('agents.schedules.toolCard.scheduling')
: t('agents.schedules.toolCard.scheduled')}
</p>
{runAt && (
<span className="text-muted-foreground text-xs">
{formatTimestamp(runAt)}
</span>
)}
</div>
{taskId && !cancelled && (
<Button
type="button"
variant="destructive-outline"
size="xs"
shape="pill"
onClick={cancel}
>
{t('agents.schedules.cancel')}
</Button>
)}
{cancelled && <ScheduleStatusBadge status="cancelled" />}
</div>
{instruction && (
<p className="text-muted-foreground mt-2 text-sm italic">
“{instruction}”
</p>
<p className="text-muted-foreground pl-7">{instruction}</p>
)}
{taskId && !cancelled && (
<Button
type="button"
variant="outline"
size="sm"
onClick={cancel}
className="text-destructive mt-2 text-xs"
>
Cancel
</Button>
)}
{cancelled && (
<p className="text-muted-foreground mt-2 text-xs">Cancelled.</p>
)}
</div>
</Card>
);
}
+223 -210
View File
@@ -4,15 +4,15 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import userService from '../../api/services/userService';
import Spinner from '../../components/Spinner';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { Button } from '../../components/ui/button';
import ConfirmationModal from '../../modals/ConfirmationModal';
import { ActiveState } from '../../models/misc';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { CurrentSectionHeader } from '../../navigation/SectionPageHeader';
import SectionPills from '../../navigation/SectionPills';
import SectionShell from '../../navigation/SectionShell';
import type { Agent } from '../types';
import type {
Schedule,
@@ -34,6 +34,10 @@ import {
updateSchedule,
} from './schedulesSlice';
// Timezones and dates carry slashes: React escapes on render, so i18next
// must not escape them first.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
@@ -148,235 +152,244 @@ export default function SchedulesView() {
};
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-6xl">
<CurrentSectionHeader />
<SectionPills className="mt-4" />
<div className="mt-6 flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
{loadingAgent ? (
<div className="flex h-[55vh] w-full items-center justify-center">
<Spinner />
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t('agents.schedules.heading')}
</h2>
)}
</div>
{loadingAgent ? (
<LoadingState fill="block" />
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<SectionHeader
title={t('agents.schedules.heading')}
actions={
<Button type="button" size="sm" onClick={openCreate}>
{t('agents.schedules.newRecurring')}
</Button>
</header>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.recurring')} ({recurring.length})
</h3>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron)} · tz:{' '}
{schedule.timezone} · next:{' '}
{formatTimestamp(schedule.next_run_at)}
}
/>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.recurring')} (${recurring.length})`}
/>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<div className="flex gap-2">
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
/>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
size="sm"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.runNow')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.delete')}
{t('agents.schedules.cancel')}
</Button>
</div>
)}
</div>
<Button
type="button"
variant="link"
size="sm"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-2 h-auto p-0 text-xs underline"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
{t('agents.schedules.oneTime')} ({oneTime.length})
</h3>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
size="sm"
onClick={() => openEdit(schedule)}
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
onClick={() => requestDelete(schedule)}
className="rounded-full px-5"
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>
</li>
))}
</ul>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="danger"
</section>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
/>
{modalOpen && (
<ScheduleFormModal
key={editing?.id ?? 'create'}
open={modalOpen}
initial={editing}
agentToolIds={agentToolIds}
onClose={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>
)
)}
</div>
</div>
)}
<ConfirmationModal
message={t('agents.schedules.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.schedules.delete')}
handleSubmit={confirmDelete}
handleCancel={() => setScheduleToDelete(null)}
variant="destructive"
/>
</div>
)
)}
</SectionShell>
);
}
@@ -1,16 +1,18 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import i18n from '../../locale/i18n';
import ScheduleStatusBadge, {
formatStatusLabel,
getStatusClasses,
getStatusVariant,
type ScheduleStatusBadgeStatus,
} from './StatusBadge';
type Case = {
status: ScheduleStatusBadgeStatus;
label: string;
classes: string[];
variant: string;
};
const CASES: Case[] = [
@@ -18,68 +20,67 @@ const CASES: Case[] = [
{
status: 'active',
label: 'Active',
classes: ['bg-green-100', 'text-green-700'],
variant: 'success',
},
{
status: 'paused',
label: 'Paused',
classes: ['bg-amber-100', 'text-amber-700'],
variant: 'warning',
},
{
status: 'completed',
label: 'Completed',
classes: ['bg-blue-100', 'text-blue-700'],
variant: 'success',
},
{
status: 'cancelled',
label: 'Cancelled',
classes: ['bg-muted', 'text-muted-foreground'],
variant: 'neutral',
},
// Run statuses
{
status: 'success',
label: 'Success',
classes: ['bg-green-100', 'text-green-700'],
variant: 'success',
},
{
status: 'failed',
label: 'Failed',
classes: ['bg-red-100', 'text-red-700'],
variant: 'destructive',
},
{
status: 'skipped',
label: 'Skipped',
classes: ['bg-amber-100', 'text-amber-700'],
variant: 'warning',
},
{
status: 'running',
label: 'Running',
classes: ['bg-blue-100', 'text-blue-700'],
variant: 'info',
},
{
status: 'pending',
label: 'Pending',
classes: ['bg-muted', 'text-muted-foreground'],
variant: 'neutral',
},
{
status: 'timeout',
label: 'Timeout',
classes: ['bg-red-100', 'text-red-700'],
variant: 'destructive',
},
];
describe('ScheduleStatusBadge', () => {
it.each(CASES)(
'renders $status with label "$label" and the right color classes',
({ status, label, classes }) => {
'renders $status with label "$label" as a $variant badge',
({ status, label, variant }) => {
const html = renderToStaticMarkup(
<ScheduleStatusBadge status={status} />,
);
expect(html).toContain(label);
expect(html).toContain(`data-status="${status}"`);
for (const cls of classes) {
expect(html).toContain(cls);
}
expect(html).toContain('data-slot="badge"');
expect(html).toContain(`data-variant="${variant}"`);
},
);
@@ -101,9 +102,21 @@ describe('formatStatusLabel', () => {
});
});
describe('getStatusClasses', () => {
it('returns muted classes for an unknown status', () => {
describe('getStatusVariant', () => {
it('returns neutral for an unknown status', () => {
// @ts-expect-error -- exercising the runtime fallback for unknown values
expect(getStatusClasses('unknown')).toContain('text-muted-foreground');
expect(getStatusVariant('unknown')).toBe('neutral');
});
});
describe('ScheduleStatusBadge locale', () => {
afterEach(async () => {
await i18n.changeLanguage('en');
});
it('reads its label from the locale files', async () => {
await i18n.changeLanguage('de');
const html = renderToStaticMarkup(<ScheduleStatusBadge status="failed" />);
expect(html).toContain('Fehlgeschlagen');
});
});
+28 -27
View File
@@ -1,4 +1,6 @@
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { ScheduleRunStatus, ScheduleStatus } from '../types/schedule';
@@ -9,23 +11,21 @@ export type ScheduleStatusBadgeProps = {
className?: string;
};
const STATUS_CLASSES: Record<ScheduleStatusBadgeStatus, string> = {
type StatusVariant = 'success' | 'warning' | 'info' | 'destructive' | 'neutral';
const STATUS_VARIANTS: Record<ScheduleStatusBadgeStatus, StatusVariant> = {
// Schedule statuses
active:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
paused:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
completed: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
cancelled: 'bg-muted text-muted-foreground',
active: 'success',
paused: 'warning',
completed: 'success',
cancelled: 'neutral',
// Run statuses
success:
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
skipped:
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
pending: 'bg-muted text-muted-foreground',
timeout: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
success: 'success',
failed: 'destructive',
skipped: 'warning',
running: 'info',
pending: 'neutral',
timeout: 'destructive',
};
/** Maps a status string to a human label (sentence-cased, underscores spaced). */
@@ -34,25 +34,26 @@ export const formatStatusLabel = (status: string): string => {
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
};
/** Returns the Tailwind classes for a given schedule/run status pill. */
export const getStatusClasses = (status: ScheduleStatusBadgeStatus): string =>
STATUS_CLASSES[status] ?? 'bg-muted text-muted-foreground';
/** Returns the Badge variant for a given schedule/run status. */
export const getStatusVariant = (
status: ScheduleStatusBadgeStatus,
): StatusVariant => STATUS_VARIANTS[status] ?? 'neutral';
/** Colored pill for schedule or run statuses. */
export default function ScheduleStatusBadge({
status,
className,
}: ScheduleStatusBadgeProps) {
const { t } = useTranslation();
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium',
getStatusClasses(status),
className,
)}
<Badge
variant={getStatusVariant(status)}
className={className}
data-status={status}
>
{formatStatusLabel(status)}
</span>
{t(`agents.schedules.status.${status}`, {
defaultValue: formatStatusLabel(status),
})}
</Badge>
);
}
@@ -125,16 +125,12 @@ export default function TimezoneCombobox({
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
variant="combobox"
role="combobox"
size="sm"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'h-9 w-full justify-between gap-2 px-3 font-normal',
!value && 'text-muted-foreground',
className,
)}
data-placeholder={value ? undefined : ''}
className={cn('w-full justify-between', className)}
>
{value ? (
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
@@ -146,12 +142,11 @@ export default function TimezoneCombobox({
) : (
<span className="truncate">{placeholder}</span>
)}
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
<ChevronsUpDown className="shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* z-200 keeps the popover above Modal (z-50); matches DatePicker. */}
<PopoverContent
className="z-200 w-[min(20rem,calc(100vw-2rem))] p-0"
className="w-[min(20rem,calc(100vw-2rem))] p-0"
align="start"
>
<Command shouldFilter={false}>
@@ -177,7 +172,7 @@ export default function TimezoneCombobox({
>
<Check
className={cn(
'mr-2 size-4 shrink-0',
'size-4 shrink-0',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
@@ -1,3 +1,4 @@
import i18next from 'i18next';
import { describe, expect, it } from 'vitest';
import type { Schedule } from '../types/schedule';
@@ -323,3 +324,64 @@ describe('formatCron', () => {
expect(formatCron(undefined)).toBe('');
});
});
describe('formatCron with a translate function', () => {
// The English locale strings (agents.schedules.cron) must read exactly like
// the untranslated fallback.
const t = i18next.createInstance();
void t.init({
lng: 'en',
initAsync: false,
resources: {
en: {
translation: {
agents: {
schedules: {
cron: {
daily: 'Daily at {{time}}',
weekly: 'Weekly on {{day}} at {{time}}',
monthly: 'Monthly on day {{day}} at {{time}}',
yearly: 'Yearly on {{month}} {{day}} at {{time}}',
custom: 'Custom: {{expression}}',
dayNames: {
sun: 'Sunday',
mon: 'Monday',
tue: 'Tuesday',
wed: 'Wednesday',
thu: 'Thursday',
fri: 'Friday',
sat: 'Saturday',
},
monthNames: {
jan: 'January',
feb: 'February',
mar: 'March',
apr: 'April',
may: 'May',
jun: 'June',
jul: 'July',
aug: 'August',
sep: 'September',
oct: 'October',
nov: 'November',
dec: 'December',
},
},
},
},
},
},
},
});
it.each([
'0 9 * * *',
'0 9 * * 1',
'15 7 * * 0',
'0 10 15 * *',
'0 8 15 3 *',
'0 9 * * 1-5',
])('matches the English fallback for %s', (cron) => {
expect(formatCron(cron, t.t)).toBe(formatCron(cron));
});
});
+62 -8
View File
@@ -1,11 +1,8 @@
import type { TFunction } from 'i18next';
import type { Schedule } from '../types/schedule';
export type ScheduleFrequency =
| 'once'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly';
'once' | 'daily' | 'weekly' | 'monthly' | 'yearly';
export type ScheduleFormValues = {
frequency: ScheduleFrequency;
@@ -312,13 +309,70 @@ const formatTime12h = (hour: number, minute: number): string => {
return `${h12}:${pad2(minute)} ${period}`;
};
/** Human-readable label for a cron string the form emits; falls back for custom shapes. */
export function formatCron(expression?: string | null): string {
const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const MONTH_KEYS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
// Cron text is plain text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* Human-readable label for a cron string the form emits; falls back for custom shapes.
*
* Args:
* expression: The cron expression.
* t: Optional i18next translate function; without it the label is English.
*
* Returns:
* The label, or an empty string when there is no expression.
*/
export function formatCron(expression?: string | null, t?: TFunction): string {
if (!expression) return '';
const parsed = parseCron(expression);
if (!parsed) return `Custom: ${expression}`;
const custom = () =>
t
? t('agents.schedules.cron.custom', { ...NO_ESCAPE, expression })
: `Custom: ${expression}`;
if (!parsed) return custom();
const { frequency, hour, minute, dom, mon, dow } = parsed;
const time = formatTime12h(hour, minute);
if (t) {
const dayIndex = (dow ?? 0) % 7;
const monthIndex = ((mon ?? 1) - 1) % 12;
switch (frequency) {
case 'daily':
return t('agents.schedules.cron.daily', { time });
case 'weekly':
return t('agents.schedules.cron.weekly', {
time,
day: t(`agents.schedules.cron.dayNames.${DAY_KEYS[dayIndex]}`),
});
case 'monthly':
return t('agents.schedules.cron.monthly', { time, day: dom });
case 'yearly':
return t('agents.schedules.cron.yearly', {
time,
day: dom,
month: t(
`agents.schedules.cron.monthNames.${MONTH_KEYS[monthIndex]}`,
),
});
default:
return custom();
}
}
switch (frequency) {
case 'daily':
return `Daily at ${time}`;
+1 -6
View File
@@ -3,12 +3,7 @@ export type ScheduleTriggerType = 'once' | 'recurring';
export type ScheduleStatus = 'active' | 'paused' | 'completed' | 'cancelled';
export type ScheduleRunStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'skipped'
| 'timeout';
'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'timeout';
// Mirrors the schedule_runs.error_type CHECK constraint (migrations 0010, 0027).
export type ScheduleRunErrorType =
+1 -7
View File
@@ -1,11 +1,5 @@
export type NodeType =
| 'start'
| 'end'
| 'agent'
| 'note'
| 'state'
| 'condition'
| 'code';
'start' | 'end' | 'agent' | 'note' | 'state' | 'condition' | 'code';
export interface CodeNodeConfig {
code: string;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,134 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { WorkflowNode } from '../types/workflow';
import type { WorkflowExecutionStep } from './workflowPreviewSlice';
import { WorkflowMiniMap } from './WorkflowPreview';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node = (
id: string,
type: WorkflowNode['type'],
y: number,
): WorkflowNode => ({
id,
type,
title: id,
position: { x: 0, y },
data: {},
});
const nodes = [
node('start', 'start', 0),
node('done', 'agent', 1),
node('broken', 'agent', 2),
node('busy', 'agent', 3),
];
const step = (
nodeId: string,
status: WorkflowExecutionStep['status'],
): WorkflowExecutionStep => ({
nodeId,
nodeType: 'agent',
nodeTitle: nodeId,
status,
});
const steps = [
step('done', 'completed'),
step('broken', 'failed'),
step('busy', 'running'),
];
describe('WorkflowMiniMap', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const rows = (activeNodeId: string | null = null) => {
act(() => {
root.render(
<WorkflowMiniMap
nodes={nodes}
activeNodeId={activeNodeId}
executionSteps={steps}
/>,
);
});
const byName: Record<string, HTMLButtonElement> = {};
container.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
byName[b.textContent ?? ''] = b;
});
return byName;
};
// No i18n instance in this test: t() renders the key.
const START = 'agents.workflow.nodes.start';
const tokens = (el: HTMLElement) => el.className.split(/\s+/);
it('pins the success fill on hover for a completed row', () => {
const done = tokens(rows().done);
expect(done).toEqual(
expect.arrayContaining([
'bg-success/10',
'hover:bg-success/10',
'dark:hover:bg-success/10',
'hover:opacity-80',
]),
);
expect(done).not.toContain('hover:bg-accent');
expect(done).not.toContain('dark:hover:bg-input/50');
});
it('pins each status fill on hover', () => {
const r = rows();
expect(tokens(r.broken)).toEqual(
expect.arrayContaining([
'hover:bg-destructive/10',
'dark:hover:bg-destructive/10',
]),
);
expect(tokens(r.busy)).toEqual(
expect.arrayContaining([
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
'animate-pulse',
]),
);
expect(tokens(r[START])).toEqual(
expect.arrayContaining([
'bg-muted',
'hover:bg-muted',
'dark:hover:bg-muted',
]),
);
expect(r[START].disabled).toBe(true);
expect(tokens(r[START])).not.toContain('hover:opacity-80');
});
it('pins the active fill on hover and keeps the ring', () => {
const done = tokens(rows('done').done);
expect(done).toEqual(
expect.arrayContaining([
'ring-2',
'ring-primary',
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
]),
);
expect(done).not.toContain('hover:bg-accent');
});
});
+164 -150
View File
@@ -1,23 +1,30 @@
import {
Bot,
CheckCircle2,
ChevronDown,
Circle,
Code2,
CircleAlert,
CircleCheck,
CircleX,
CodeXml,
Database,
FileBox,
Flag,
GitBranch,
Loader2,
type LucideIcon,
MessageSquare,
Play,
StickyNote,
Workflow,
XCircle,
} from 'lucide-react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { SectionHeader } from '@/components/ui/section-header';
import { SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
MessageScroller,
MessageScrollerButton,
@@ -28,7 +35,6 @@ import {
} from '@/components/ui/message-scroller';
import { cn } from '@/lib/utils';
import ChevronDownIcon from '../../assets/chevron-down.svg';
import MessageInput from '../../components/MessageInput';
import ConversationBubble from '../../conversation/ConversationBubble';
import { Query } from '../../conversation/conversationModels';
@@ -66,24 +72,26 @@ interface WorkflowPreviewProps {
workflowId?: string | null;
}
const NODE_ICONS: Record<string, React.ReactNode> = {
start: <Play className="h-3 w-3" />,
agent: <Bot className="h-3 w-3" />,
end: <Flag className="h-3 w-3" />,
note: <StickyNote className="h-3 w-3" />,
state: <Database className="h-3 w-3" />,
condition: <GitBranch className="h-3 w-3" />,
code: <Code2 className="h-3 w-3" />,
// Components, not elements: the step list draws them at 12px, the minimap
// rows at 14px.
const NODE_ICONS: Record<string, LucideIcon> = {
start: Play,
agent: Bot,
end: Flag,
note: StickyNote,
state: Database,
condition: GitBranch,
code: CodeXml,
};
const NODE_COLORS: Record<string, string> = {
start: 'text-green-600 dark:text-green-400',
agent: 'text-purple-600 dark:text-purple-400',
end: 'text-gray-600 dark:text-gray-400',
note: 'text-yellow-600 dark:text-yellow-400',
state: 'text-blue-600 dark:text-blue-400',
condition: 'text-orange-600 dark:text-orange-400',
code: 'text-indigo-600 dark:text-indigo-400',
start: 'text-success',
agent: 'text-primary',
end: 'text-destructive',
note: 'text-warning',
state: 'text-info',
condition: 'text-warning',
code: 'text-info',
};
function ExecutionDetails({
@@ -99,6 +107,7 @@ function ExecutionDetails({
onToggle: () => void;
stepRefs?: React.RefObject<Map<string, HTMLDivElement>>;
}) {
const { t } = useTranslation();
const completedSteps = steps.filter(
(s) => s.status === 'completed' || s.status === 'failed',
);
@@ -116,43 +125,44 @@ function ExecutionDetails({
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<Workflow className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
className="-ml-2.5"
>
<p className="text-base font-semibold">
Execution Details
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
({completedSteps.length}{' '}
{completedSteps.length === 1 ? 'step' : 'steps'})
{t('agents.workflow.preview.executionDetails')}
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
{t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
</span>
</p>
<img
src={ChevronDownIcon}
alt="ChevronDown"
<ChevronDown
className={cn(
'h-4 w-4 transform transition-transform duration-200 dark:invert',
isOpen ? 'rotate-180' : '',
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-all duration-300 ease-in-out',
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="space-y-2 pr-2">
<div className="flex flex-col gap-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
@@ -175,10 +185,10 @@ function ExecutionDetails({
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="bg-muted dark:bg-accent rounded-xl p-3"
className="bg-muted rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
@@ -187,43 +197,44 @@ function ExecutionDetails({
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
{NODE_ICONS[step.nodeType] || (
<Circle className="h-3 w-3" />
)}
<StepIcon className="size-3" />
</div>
<span className="min-w-0 truncate font-medium text-gray-900 dark:text-white">
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<CircleX className="text-destructive size-4" />
)}
</div>
</div>
{(hasOutput || step.error || stateVars.length > 0) && (
<div className="mt-3 space-y-2 text-sm">
<div className="mt-3 flex flex-col gap-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="font-medium text-gray-600 dark:text-gray-400">
Output:{' '}
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-gray-900 dark:text-gray-100">
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
</span>
</div>
)}
{step.error && (
<div className="rounded-lg bg-red-50 p-2 dark:bg-red-900/30">
<span className="font-medium text-red-700 dark:text-red-300">
Error:{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-red-800 dark:text-red-200">
{step.error}
</span>
</div>
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
@@ -232,11 +243,11 @@ function ExecutionDetails({
key={key}
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
>
<span className="max-w-[100px] truncate font-medium text-gray-600 dark:text-gray-400">
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
{key}:
</span>
<span
className="ml-1 max-w-[200px] truncate text-gray-900 dark:text-gray-100"
className="text-foreground ml-1 max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
@@ -268,32 +279,34 @@ function RunArtifactsSection({
onToggle: () => void;
runInProgress?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<FileBox className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<FileBox className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
className="-ml-2.5"
>
<p className="text-base font-semibold">Artifacts</p>
<img
src={ChevronDownIcon}
alt="ChevronDown"
<p className="text-base font-semibold">
{t('agents.workflow.preview.artifacts')}
</p>
<ChevronDown
className={cn(
'h-4 w-4 transform transition-transform duration-200 dark:invert',
isOpen ? 'rotate-180' : '',
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-all duration-300 ease-in-out',
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
@@ -312,7 +325,7 @@ function RunArtifactsSection({
);
}
function WorkflowMiniMap({
export function WorkflowMiniMap({
nodes,
activeNodeId,
executionSteps,
@@ -323,9 +336,10 @@ function WorkflowMiniMap({
executionSteps: WorkflowExecutionStep[];
onNodeClick?: (nodeId: string) => void;
}) {
const { t } = useTranslation();
const getNodeDisplayName = (node: WorkflowNode) => {
if (node.type === 'start') return 'Start';
if (node.type === 'end') return 'End';
if (node.type === 'start') return t('agents.workflow.nodes.start');
if (node.type === 'end') return t('agents.workflow.nodes.end');
return node.title || node.data?.title || node.type;
};
@@ -341,26 +355,6 @@ function WorkflowMiniMap({
return step?.status || 'pending';
};
const getStatusColor = (nodeId: string) => {
const status = getNodeStatus(nodeId);
const isActive = nodeId === activeNodeId;
if (isActive) {
return 'ring-2 ring-purple-500 bg-purple-100 dark:bg-purple-900/50';
}
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700';
case 'running':
return 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 animate-pulse';
case 'failed':
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
default:
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const executedOrder = new Map(executionSteps.map((s, i) => [s.nodeId, i]));
const startNode = nodes.find((node) => node.type === 'start');
const visibleNodeIds = new Set(executionSteps.map((step) => step.nodeId));
@@ -391,56 +385,75 @@ function WorkflowMiniMap({
};
return (
<div className="space-y-1">
{sortedNodes.map((node, index) => (
<div key={node.id} className="relative">
{index < sortedNodes.length - 1 && (
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
)}
<Button
type="button"
variant="outline"
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
disabled={!hasStepData(node.id)}
className={cn(
'h-12 w-full justify-start gap-2 px-3 text-xs disabled:opacity-100',
getStatusColor(node.id),
hasStepData(node.id) && 'hover:opacity-80',
<div className="flex flex-col gap-1">
{sortedNodes.map((node, index) => {
const status = getNodeStatus(node.id);
const NodeIcon = NODE_ICONS[node.type] || Circle;
const isActive = node.id === activeNodeId;
return (
<div key={node.id} className="relative">
{index < sortedNodes.length - 1 && (
<div className="bg-border absolute top-12 left-4 h-3 w-0.5" />
)}
>
<div
<Button
type="button"
variant="outline"
size="xs"
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
disabled={!hasStepData(node.id)}
/* eslint-disable shadcn/no-restyle --
Preview minimap status row: the fill, border and ring follow the step status (success, primary running + pulse,
destructive, muted pending), pending and running rows stay unfaded while disabled, and clickable rows dim on hover.
No Button variant is status-tinted. See DESIGN.md, Approved exceptions. */
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
NODE_COLORS[node.type] || NODE_COLORS.state,
'h-12 w-full justify-start disabled:opacity-100',
isActive
? 'bg-primary/10 ring-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10 ring-2'
: status === 'completed'
? 'border-success/50 bg-success/10 hover:bg-success/10 dark:border-success/50 dark:bg-success/10 dark:hover:bg-success/10'
: status === 'running'
? 'border-primary/50 bg-primary/10 hover:bg-primary/10 dark:border-primary/50 dark:bg-primary/10 dark:hover:bg-primary/10 animate-pulse'
: status === 'failed'
? 'border-destructive/50 bg-destructive/10 hover:bg-destructive/10 dark:border-destructive/50 dark:bg-destructive/10 dark:hover:bg-destructive/10'
: 'border-border bg-muted hover:bg-muted dark:border-border dark:bg-muted dark:hover:bg-muted',
hasStepData(node.id) && 'hover:opacity-80',
)}
/* eslint-enable shadcn/no-restyle */
>
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium text-gray-700 dark:text-gray-200">
{getNodeDisplayName(node)}
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
NODE_COLORS[node.type] || NODE_COLORS.state,
)}
>
<NodeIcon className="size-3.5" />
</div>
{getNodeSubtitle(node) && (
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
{getNodeSubtitle(node)}
<div className="min-w-0 flex-1 text-left">
<div className="text-foreground truncate font-medium">
{getNodeDisplayName(node)}
</div>
)}
</div>
<div className="shrink-0">
{getNodeStatus(node.id) === 'running' && (
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
)}
{getNodeStatus(node.id) === 'completed' && (
<CheckCircle2 className="h-3 w-3 text-green-500" />
)}
{getNodeStatus(node.id) === 'failed' && (
<XCircle className="h-3 w-3 text-red-500" />
)}
</div>
</Button>
</div>
))}
{getNodeSubtitle(node) && (
<div className="text-muted-foreground truncate text-xs">
{getNodeSubtitle(node)}
</div>
)}
</div>
<div className="shrink-0">
{status === 'running' && (
<Spinner size="xs" className="text-primary" />
)}
{status === 'completed' && (
<CircleCheck className="text-success size-3.5" />
)}
{status === 'failed' && (
<CircleX className="text-destructive size-3.5" />
)}
</div>
</Button>
</div>
);
})}
</div>
);
}
@@ -449,6 +462,7 @@ export default function WorkflowPreview({
workflowData,
workflowId,
}: WorkflowPreviewProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectWorkflowPreviewQueries) as WorkflowQuery[];
@@ -605,23 +619,21 @@ export default function WorkflowPreview({
<div className="bg-card flex h-full flex-col">
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
<div className="flex items-center gap-3">
<div className="bg-muted flex items-center justify-center rounded-full p-3 text-gray-600 dark:text-gray-300">
<Play className="h-4 w-4" />
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="size-4" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Preview
</h2>
<p className="max-w-md truncate text-xs text-gray-500 dark:text-gray-400">
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-primary dark:text-primary flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Running
<span className="text-primary flex items-center gap-1 text-xs">
<Spinner size="xs" />
{t('agents.schedules.status.running')}
</span>
)}
</div>
@@ -629,11 +641,13 @@ export default function WorkflowPreview({
<div className="flex min-h-0 flex-1">
<div className="border-border flex w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
Workflow
</h3>
<SectionHeader
as="h3"
size="sm"
title={t('agents.workflow.preview.minimapHeading')}
/>
</div>
<div className="flex-1 scrollbar-thin overflow-y-auto p-3">
<div className="flex-1 overflow-y-auto p-3">
<WorkflowMiniMap
nodes={workflowData.nodes}
activeNodeId={activeNodeId}
@@ -650,16 +664,16 @@ export default function WorkflowPreview({
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Test the workflow
<p className="text-foreground text-xl font-semibold">
{t('agents.workflow.preview.emptyTitle')}
</p>
</div>
) : (
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="scrollbar-thin px-4 pt-4">
<MessageScrollerViewport className="px-4 pt-4">
<MessageScrollerContent className="w-full">
{queries.map((query, index) => {
const querySteps = query.executionSteps || [];
@@ -776,8 +790,8 @@ export default function WorkflowPreview({
</div>
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-xs text-red-500" role="alert">
{sendBlockedMessage}
<p className="text-destructive text-xs" role="alert">
{t(sendBlockedMessage)}
</p>
)}
<MessageInput
@@ -0,0 +1,95 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
}));
const listMock = vi.fn();
const detailMock = vi.fn();
vi.mock('../../api/services/userService', () => ({
default: {
listWorkflowRunArtifacts: (...args: unknown[]) => listMock(...args),
getDocumentArtifact: (...args: unknown[]) => detailMock(...args),
},
}));
vi.mock('../../components/DocumentArtifactView', () => ({
default: () => <div data-testid="artifact-view" />,
}));
import WorkflowRunArtifacts from './WorkflowRunArtifacts';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const failed = { ok: false, status: 500, json: async () => ({}) };
describe('WorkflowRunArtifacts', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
listMock.mockReset();
detailMock.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(<WorkflowRunArtifacts workflowRunId="run-1" />);
});
};
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(text),
);
it('shows a failed list load as an alert with Retry that refetches', async () => {
listMock.mockResolvedValue(failed);
await render();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain(
'agents.workflow.artifacts.loadListFailed',
);
expect(listMock).toHaveBeenCalledTimes(1);
await act(async () => buttonByText('retry')!.click());
expect(listMock).toHaveBeenCalledTimes(2);
});
it('retries a failed artifact preview', async () => {
listMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
artifacts: [
{ id: 'a1', kind: 'pdf', title: 'Report', current_version: 1 },
],
}),
});
detailMock.mockResolvedValue(failed);
await render();
await act(async () => buttonByText('Report')!.click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'agents.workflow.artifacts.loadFailed',
);
expect(detailMock).toHaveBeenCalledTimes(1);
await act(async () => buttonByText('retry')!.click());
expect(detailMock).toHaveBeenCalledTimes(2);
});
});
@@ -1,10 +1,12 @@
import { ChevronLeft, FileBox } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import DocumentArtifactView from '../../components/DocumentArtifactView';
import Spinner from '../../components/Spinner';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import {
isDocumentArtifact,
type DocumentArtifact,
@@ -12,6 +14,11 @@ import {
import { Button } from '@/components/ui/button';
import { selectToken } from '../../preferences/preferenceSlice';
// Error states hold locale keys; the render translates them.
const PREVIEW_UNSUPPORTED = 'agents.workflow.artifacts.previewUnsupported';
const LIST_FAILED = 'agents.workflow.artifacts.loadListFailed';
const DETAIL_FAILED = 'agents.workflow.artifacts.loadFailed';
interface RunArtifactSummary {
id: string;
kind: string | null;
@@ -31,6 +38,7 @@ export default function WorkflowRunArtifacts({
workflowRunId,
inProgress = false,
}: WorkflowRunArtifactsProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [artifacts, setArtifacts] = useState<RunArtifactSummary[] | null>(null);
const [loading, setLoading] = useState(true);
@@ -58,7 +66,7 @@ export default function WorkflowRunArtifacts({
if (res.status === 403) {
setArtifacts([]);
} else {
setError('Failed to load artifacts');
setError(LIST_FAILED);
}
setLoading(false);
return;
@@ -70,7 +78,7 @@ export default function WorkflowRunArtifacts({
})
.catch(() => {
if (cancelled) return;
setError('Failed to load artifacts');
setError(LIST_FAILED);
setLoading(false);
});
return () => {
@@ -93,7 +101,7 @@ export default function WorkflowRunArtifacts({
.then(async (res: Response) => {
if (cancelled) return;
if (!res.ok) {
setDetailError('Failed to load artifact');
setDetailError(DETAIL_FAILED);
setDetailLoading(false);
return;
}
@@ -103,13 +111,13 @@ export default function WorkflowRunArtifacts({
setDetail(data.artifact);
setDetailLoading(false);
} else {
setDetailError('This artifact cannot be previewed');
setDetailError(PREVIEW_UNSUPPORTED);
setDetailLoading(false);
}
})
.catch(() => {
if (cancelled) return;
setDetailError('Failed to load artifact');
setDetailError(DETAIL_FAILED);
setDetailLoading(false);
});
return () => {
@@ -131,29 +139,41 @@ export default function WorkflowRunArtifacts({
if (loading) {
return (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<Spinner size="small" /> Loading artifacts...
</div>
<LoadingState
fill="block"
size="sm"
label={t('agents.workflow.artifacts.loading')}
/>
);
}
if (error) {
return (
<div className="flex items-center justify-between gap-2 px-3 py-3 text-sm text-red-500">
<span>{error}</span>
<Button type="button" variant="outline" size="sm" onClick={loadList}>
Retry
</Button>
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={t(error)}
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => loadList()}
>
{t('retry')}
</Button>
}
/>
);
}
if (!artifacts || artifacts.length === 0) {
return (
<div className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
<div className="text-muted-foreground px-3 py-3 text-sm">
{inProgress
? 'Run in progress — artifacts appear here as nodes produce them.'
: 'No artifacts produced by this run.'}
? t('agents.workflow.artifacts.inProgress')
: t('agents.workflow.artifacts.empty')}
</div>
);
}
@@ -166,22 +186,41 @@ export default function WorkflowRunArtifacts({
type="button"
variant="ghost"
size="sm"
className="gap-1 px-2"
onClick={() => setSelectedId(null)}
>
<ChevronLeft className="h-4 w-4" />
Back to artifacts
<ChevronLeft />
{t('agents.workflow.artifacts.back')}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{detailLoading ? (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
<LoadingState fill="parent" />
) : detailError === PREVIEW_UNSUPPORTED ? (
// Not a failed load: nothing to retry.
<EmptyState
size="sm"
illustration="none"
title={t(detailError)}
className="h-full"
/>
) : detailError ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{detailError}</p>
</div>
<EmptyState
tone="destructive"
size="sm"
illustration="none"
title={t(detailError)}
className="h-full"
action={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fetchDetail(selectedId)}
>
{t('retry')}
</Button>
}
/>
) : detail ? (
<DocumentArtifactView
artifact={detail}
@@ -201,15 +240,18 @@ export default function WorkflowRunArtifacts({
type="button"
variant="outline"
onClick={() => setSelectedId(artifact.id)}
className="h-auto w-full justify-start gap-3 px-3 py-2 text-left"
className="h-auto w-full justify-start text-left"
>
<FileBox className="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" />
<FileBox className="text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
{artifact.title || `Artifact ${artifact.id.slice(0, 8)}`}
<div className="text-foreground truncate text-sm font-medium">
{artifact.title ||
t('agents.workflow.artifacts.untitled', {
id: artifact.id.slice(0, 8),
})}
</div>
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
{artifact.kind || 'file'}
<div className="text-muted-foreground truncate text-xs">
{artifact.kind || t('agents.workflow.artifacts.fileKind')}
{artifact.current_version != null
? ` · v${artifact.current_version}`
: ''}
@@ -1,17 +1,18 @@
import { Monitor } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function MobileBlocker() {
const { t } = useTranslation();
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center md:hidden">
<div className="bg-primary/10 dark:bg-primary/20 mb-6 flex h-20 w-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary h-10 w-10" />
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-secondary mb-6 flex size-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary size-10" />
</div>
<h2 className="text-foreground mb-2 text-xl font-bold">
Desktop Required
<h2 className="text-foreground mb-2 text-xl leading-tight font-semibold">
{t('agents.workflow.mobileBlocker.title')}
</h2>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
The Workflow Builder requires a larger screen for the best experience.
Please open this page on a desktop or laptop computer.
{t('agents.workflow.mobileBlocker.body')}
</p>
</div>
);
@@ -1,9 +1,12 @@
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
appendDocumentRef,
@@ -21,10 +24,10 @@ interface NodeDocumentsControlProps {
helpText?: string;
}
const MODE_OPTIONS: { mode: DocumentsMode; label: string }[] = [
{ mode: 'all', label: 'All input docs' },
{ mode: 'none', label: 'None' },
{ mode: 'choose', label: 'Choose…' },
const MODE_OPTIONS: { mode: DocumentsMode; labelKey: string }[] = [
{ mode: 'all', labelKey: 'agents.workflow.documents.modeAll' },
{ mode: 'none', labelKey: 'agents.workflow.documents.modeNone' },
{ mode: 'choose', labelKey: 'agents.workflow.documents.modeChoose' },
];
/** Shared All/None/Choose documents picker for agent and code workflow nodes. */
@@ -35,12 +38,14 @@ export default function NodeDocumentsControl({
label,
helpText,
}: NodeDocumentsControlProps) {
const { t } = useTranslation();
// Track mode in component state so "Choose" stays reachable even when the
// chosen list is empty (an empty list otherwise reads back as "None").
const [mode, setMode] = useState<DocumentsMode>(() =>
getDocumentsMode(value),
);
const [refDraft, setRefDraft] = useState('');
const fieldId = useId();
const chosen = documentsModeToInputDocuments('choose', value);
const showChoose = mode === 'choose';
@@ -59,42 +64,46 @@ export default function NodeDocumentsControl({
};
return (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
{MODE_OPTIONS.map(({ mode: optionMode, label: modeLabel }) => (
<Button
key={optionMode}
type="button"
variant="ghost"
size="sm"
onClick={() => selectMode(optionMode)}
className={`h-auto flex-1 rounded-lg px-2 py-1.5 text-xs font-medium ${
mode === optionMode
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-300'
}`}
>
{modeLabel}
</Button>
))}
<FormField label={label} hint={helpText} id={fieldId} float={false}>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="border-border bg-card rounded-xl border p-1">
<ToggleGroup
id={fieldId}
type="single"
size="xs"
value={mode}
onValueChange={(next) => next && selectMode(next as DocumentsMode)}
aria-label={label}
className="flex-nowrap"
>
{MODE_OPTIONS.map(({ mode: optionMode, labelKey }) => (
<ToggleGroupItem
key={optionMode}
value={optionMode}
className="flex-1"
>
{t(labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{showChoose && (
<div className="mt-2 flex flex-col gap-2">
<div className="flex flex-col gap-2">
<MultiSelect
// Its own id: the FormField id belongs to the mode group above.
id={`${fieldId}-pick`}
options={withChosenDocumentOptions(options, chosen)}
selected={chosen}
onChange={(next) =>
onChange(documentsModeToInputDocuments('choose', next))
}
placeholder="Select documents..."
searchPlaceholder="Search variables..."
emptyText="No upstream documents"
placeholder={t('agents.workflow.documents.selectPlaceholder')}
searchPlaceholder={t('agents.workflow.documents.searchPlaceholder')}
emptyText={t('agents.workflow.documents.empty')}
/>
<div className="flex gap-2">
<Input
id={`${fieldId}-ref`}
type="text"
value={refDraft}
onChange={(e) => setRefDraft(e.target.value)}
@@ -104,25 +113,20 @@ export default function NodeDocumentsControl({
addRef();
}
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Add ref (e.g. A1)"
placeholder={t('agents.workflow.documents.refPlaceholder')}
/>
<Button
type="button"
variant="ghost"
size="sm"
variant="ghost-muted"
onClick={addRef}
className="h-auto shrink-0 gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300"
className="shrink-0"
>
<Plus size={14} />
Add
<Plus className="size-3.5" />
{t('agents.form.buttons.add')}
</Button>
</div>
</div>
)}
{helpText && (
<p className="text-muted-foreground mt-1 text-xs">{helpText}</p>
)}
</div>
</FormField>
);
}
@@ -0,0 +1,77 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
import PromptTextArea from './PromptTextArea';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('PromptTextArea mention menu', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (value: string) =>
act(() => {
root.render(
<PromptTextArea
value={value}
onChange={() => undefined}
nodes={[]}
edges={[]}
selectedNodeId="n1"
/>,
);
});
const menu = () =>
document.querySelector('[data-slot="popover-content"]') as HTMLElement;
it('opens a portalled popover on "{{" and keeps focus in the textarea', () => {
render('{{');
const textarea = container.querySelector('textarea')!;
act(() => {
textarea.focus();
textarea.setSelectionRange(2, 2);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
expect(menu()).not.toBeNull();
expect(container.contains(menu())).toBe(false);
expect(menu().textContent).toContain('source.content');
expect(document.activeElement).toBe(textarea);
});
it('closes when the trigger text is gone', () => {
render('{{');
const textarea = container.querySelector('textarea')!;
act(() => {
textarea.focus();
textarea.setSelectionRange(2, 2);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
render('hello');
act(() => {
textarea.setSelectionRange(5, 5);
textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
expect(menu()).toBeNull();
});
});
@@ -1,11 +1,15 @@
import { Braces, Plus, Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Edge, Node } from 'reactflow';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { FormField, FormFieldBoundary } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
@@ -199,6 +203,16 @@ export function extractUpstreamVariables(
return variables;
}
// Built-in section names (and the unnamed-node fallbacks) shown translated;
// a node's own title is a value and shows as written.
const SECTION_LABEL_KEYS: Record<string, string> = {
'Global context': 'agents.workflow.variables.globalContext',
'Workflow input': 'agents.workflow.variables.workflowInput',
Agent: 'agents.workflow.nodes.agent',
Code: 'agents.workflow.nodes.code',
'Set State': 'agents.workflow.nodes.setState',
};
function groupBySection(
vars: WorkflowVariable[],
): Map<string, WorkflowVariable[]> {
@@ -221,7 +235,7 @@ function HighlightedOverlay({ text }: { text: string }) {
{part}
</span>
) : (
<span key={i} className="text-gray-900 dark:text-white">
<span key={i} className="text-foreground">
{part}
</span>
),
@@ -237,7 +251,12 @@ function VariableListWithSearch({
variables: WorkflowVariable[];
onSelect: (templatePath: string) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
// Own id, and a FormFieldBoundary below: the "Add context" popover renders
// inside PromptTextArea's FormField (through a portal), whose context would
// otherwise hand this search box the textarea's id and floating placeholder.
const searchId = useId();
const filtered = useMemo(
() =>
@@ -252,52 +271,60 @@ function VariableListWithSearch({
const grouped = useMemo(() => groupBySection(filtered), [filtered]);
return (
<div className="flex w-full flex-col overflow-hidden">
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search variables..."
className="h-auto rounded-none border-0 px-0 py-0 text-sm text-gray-800 shadow-none focus-visible:ring-0 md:text-sm dark:border-0 dark:text-gray-200"
/>
</div>
<FormFieldBoundary>
<div className="flex w-full flex-col overflow-hidden">
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
<Search className="text-muted-foreground size-3.5 shrink-0" />
<Input
id={searchId}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('agents.workflow.variables.searchPlaceholder')}
variant="bare"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-muted-foreground px-3 py-4 text-center text-xs">
No variables found
</div>
) : (
Array.from(grouped.entries()).map(([section, vars]) => (
<div key={section}>
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-xs font-semibold tracking-wider uppercase">
{section}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={t('agents.workflow.variables.empty')}
/>
) : (
Array.from(grouped.entries()).map(([section, vars]) => (
<div key={section}>
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-xs font-semibold tracking-wider uppercase">
{SECTION_LABEL_KEYS[section]
? t(SECTION_LABEL_KEYS[section])
: section}
</div>
{vars.map((v) => (
<Button
key={`${section}-${v.templatePath}`}
type="button"
variant="ghost"
size="sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.templatePath);
}}
className="w-full justify-start"
>
<Braces className="text-primary size-3.5 shrink-0" />
<span className="text-foreground truncate font-medium">
{v.label}
</span>
</Button>
))}
</div>
{vars.map((v) => (
<Button
key={`${section}-${v.templatePath}`}
type="button"
variant="ghost"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.templatePath);
}}
className="h-auto w-full justify-start gap-2 rounded-none px-3 py-1.5 text-left text-sm font-normal"
>
<Braces className="text-primary h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
{v.label}
</span>
</Button>
))}
</div>
))
)}
))
)}
</div>
</div>
</div>
</FormFieldBoundary>
);
}
@@ -322,12 +349,11 @@ export default function PromptTextArea({
rows = 4,
label,
}: PromptTextAreaProps) {
const { t } = useTranslation();
const textareaId = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const [filterText, setFilterText] = useState('');
const [cursorInsertPos, setCursorInsertPos] = useState<number | null>(null);
const [contextOpen, setContextOpen] = useState(false);
@@ -359,14 +385,6 @@ export default function PromptTextArea({
if (triggerMatch) {
setFilterText(triggerMatch[1].trim());
setCursorInsertPos(cursorPos);
const wrapper = wrapperRef.current;
if (!wrapper) return;
setDropdownPos({
top: wrapper.offsetHeight + 4,
left: 0,
});
setShowDropdown(true);
} else {
setShowDropdown(false);
@@ -420,33 +438,11 @@ export default function PromptTextArea({
[value, onChange],
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as HTMLElement)
) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [showDropdown]);
return (
<div>
{label && (
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<div
ref={wrapperRef}
className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-all focus-within:ring-2"
>
// The mention menu opens under the field and leaves focus in the textarea,
// so typing keeps filtering it.
const field = (
<PopoverAnchor asChild>
<div className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-shadow focus-within:ring-2">
<div
ref={overlayRef}
aria-hidden
@@ -455,13 +451,22 @@ export default function PromptTextArea({
{value ? (
<HighlightedOverlay text={value} />
) : (
<span className="text-gray-400 dark:text-gray-500">
// Under a floating label the example waits for focus, like
// ui/Textarea, so it doesn't collide with the resting label.
<span
className={
label
? 'text-muted-foreground invisible group-focus-within/float:visible'
: 'text-muted-foreground'
}
>
{placeholder}
</span>
)}
</div>
<textarea
id={textareaId}
ref={textareaRef}
value={value}
onChange={(e) => {
@@ -481,11 +486,7 @@ export default function PromptTextArea({
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
}
}}
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none focus-visible:ring-[3px] dark:caret-white"
style={{
color: 'transparent',
WebkitTextFillColor: 'transparent',
}}
className="focus-visible:ring-ring/50 focus-visible:border-ring caret-foreground relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm text-transparent outline-none focus-visible:ring-3"
rows={rows}
placeholder={placeholder}
spellCheck={false}
@@ -494,19 +495,15 @@ export default function PromptTextArea({
<div className="absolute right-4 bottom-1.5 z-10">
<Popover open={contextOpen} onOpenChange={setContextOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="text-primary hover:bg-primary/10 h-auto gap-1 px-2 py-1 text-xs font-medium"
>
<Plus className="h-3 w-3" />
Add context
<Button type="button" variant="link" size="xs">
<Plus className="size-3" />
{t('agents.workflow.variables.addContext')}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="border-border bg-card w-60 rounded-xl border p-0 shadow-lg"
className="w-60 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VariableListWithSearch
@@ -516,20 +513,39 @@ export default function PromptTextArea({
</PopoverContent>
</Popover>
</div>
{showDropdown && filtered.length > 0 && (
<div
ref={dropdownRef}
className="border-border bg-card absolute z-50 w-64 rounded-xl border shadow-lg"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<VariableListWithSearch
variables={filtered}
onSelect={insertVariable}
/>
</div>
)}
</div>
</div>
</PopoverAnchor>
);
return (
<Popover
open={showDropdown && filtered.length > 0}
onOpenChange={(open) => {
if (!open) setShowDropdown(false);
}}
>
{label ? (
<FormField label={label} id={textareaId}>
{field}
</FormField>
) : (
field
)}
<PopoverContent
align="start"
className="w-64 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (textareaRef.current?.contains(e.target as HTMLElement)) {
e.preventDefault();
}
}}
>
<VariableListWithSearch
variables={filtered}
onSelect={insertVariable}
/>
</PopoverContent>
</Popover>
);
}
+30 -20
View File
@@ -1,6 +1,8 @@
import React, { ReactNode } from 'react';
import { Handle, Position } from 'reactflow';
import { cn } from '@/lib/utils';
interface BaseNodeProps {
title: string;
children?: ReactNode;
@@ -23,60 +25,68 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
}) => {
let bgColor = 'bg-card';
let borderColor = 'border-border';
let iconBg = 'bg-gray-100 dark:bg-gray-800';
let iconColor = 'text-gray-600 dark:text-gray-400';
let iconBg = 'bg-muted';
let iconColor = 'text-muted-foreground';
if (selected) {
borderColor = 'border-primary ring-2 ring-primary';
}
if (type === 'start') {
iconBg = 'bg-green-100 dark:bg-green-900/30';
iconColor = 'text-green-600 dark:text-green-400';
iconBg = 'bg-success/10';
iconColor = 'text-success';
} else if (type === 'end') {
iconBg = 'bg-red-100 dark:bg-red-900/30';
iconColor = 'text-red-600 dark:text-red-400';
iconBg = 'bg-destructive/10';
iconColor = 'text-destructive';
} else if (type === 'state') {
iconBg = 'bg-gray-100 dark:bg-gray-800';
iconColor = 'text-gray-600 dark:text-gray-400';
iconBg = 'bg-muted';
iconColor = 'text-muted-foreground';
} else if (type === 'condition') {
iconBg = 'bg-orange-100 dark:bg-orange-900/30';
iconColor = 'text-orange-600 dark:text-orange-400';
iconBg = 'bg-warning/10';
iconColor = 'text-warning';
} else if (type === 'code') {
iconBg = 'bg-indigo-100 dark:bg-indigo-900/30';
iconColor = 'text-indigo-600 dark:text-indigo-400';
iconBg = 'bg-info/10';
iconColor = 'text-info';
}
return (
<div
className={`rounded-full border ${bgColor} ${borderColor} shadow-md transition-all hover:shadow-lg ${
selected ? 'scale-105' : ''
} max-w-[250px] min-w-[180px]`}
className={cn(
'rounded-full border shadow-md transition hover:shadow-lg',
bgColor,
borderColor,
selected && 'scale-105',
'max-w-[250px] min-w-[180px]',
)}
>
{handles.target && (
<Handle
type="target"
position={Position.Left}
isConnectable={true}
className="hover:bg-primary/90! border-card! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${iconBg} ${iconColor}`}
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
iconBg,
iconColor,
)}
>
{icon}
</div>
<div className="min-w-0 flex-1 pr-3">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
{children && (
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">
<div className="text-muted-foreground mt-1 truncate text-xs">
{children}
</div>
)}
@@ -88,7 +98,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
type="source"
position={Position.Right}
isConnectable={true}
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
)}
</div>
@@ -1,10 +1,14 @@
import { Code2 } from 'lucide-react';
import { CodeXml } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
type CodeNodeData = {
title?: string;
label?: string;
@@ -12,7 +16,8 @@ type CodeNodeData = {
};
const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
const title = data.title || data.label || 'Code';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.code');
const config = data.config || {};
const code = (config.code || '').trim();
const firstLine = code.split('\n').find((line) => line.trim() !== '') || '';
@@ -24,28 +29,34 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
title={title}
type="code"
selected={selected}
icon={<Code2 size={16} />}
icon={<CodeXml className="size-4" />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{codeHint ? (
<div
className="truncate font-mono text-xs text-gray-500 dark:text-gray-400"
className="text-muted-foreground truncate font-mono text-xs"
title={code}
>
{codeHint}
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400">
No code yet
<div className="text-muted-foreground text-xs">
{t('agents.workflow.nodes.noCode')}
</div>
)}
{config.output_variable && (
<div
className="truncate text-xs text-gray-500 dark:text-gray-400"
title={`Output: ${config.output_variable}`}
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
Output: {config.output_variable}
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
)}
</div>
@@ -1,7 +1,10 @@
import { GitBranch } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Handle, NodeProps, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { ConditionCase } from '../../types/workflow';
type ConditionNodeData = {
@@ -29,40 +32,45 @@ function getHandleTop(index: number, total: number): string {
}
const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
const title = data.title || data.label || 'If / Else';
const { t } = useTranslation();
const title =
data.title || data.label || t('agents.workflow.nodes.condition');
const cases = data.config?.cases || [];
const totalOutputs = cases.length + 1;
const height = getNodeHeight(cases.length);
return (
<div
className={`bg-card relative rounded-2xl border shadow-md transition-all ${
className={cn(
'bg-card relative rounded-2xl border shadow-md transition',
selected
? 'border-primary dark:ring-primary scale-105 ring-2 ring-purple-300'
: 'border-border hover:shadow-lg'
}`}
? 'border-primary ring-primary scale-105 ring-2'
: 'border-border hover:shadow-lg',
)}
style={{ minWidth: 180, maxWidth: 220, height }}
>
<Handle
type="target"
position={Position.Left}
isConnectable
className="hover:bg-primary/90! border-card! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! top-1/2! -left-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
<GitBranch size={14} />
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
<GitBranch className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-2">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
<div className="text-xs text-gray-500 uppercase">
{data.config?.mode || 'simple'}
<div className="text-muted-foreground text-xs">
{data.config?.mode === 'advanced'
? t('agents.workflow.nodes.modeAdvanced')
: t('agents.workflow.nodes.modeSimple')}
</div>
</div>
</div>
@@ -74,12 +82,14 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
className="flex items-center gap-1"
style={{ height: ROW_HEIGHT }}
>
<span className="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">
{i === 0 ? 'If' : 'Else if'}
<span className="text-warning shrink-0 text-xs font-medium">
{i === 0
? t('agents.workflow.nodes.if')
: t('agents.workflow.nodes.elseIf')}
</span>
{c.name && (
<span
className="truncate text-xs text-gray-600 dark:text-gray-400"
className="text-muted-foreground truncate text-xs"
title={c.name}
>
{c.name}
@@ -88,7 +98,9 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
</div>
))}
<div className="flex items-center gap-1" style={{ height: ROW_HEIGHT }}>
<span className="text-xs font-medium text-gray-500">Else</span>
<span className="text-muted-foreground text-xs font-medium">
{t('agents.workflow.nodes.else')}
</span>
</div>
</div>
@@ -100,7 +112,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
id={c.sourceHandle}
isConnectable
style={{ top: getHandleTop(i, totalOutputs) }}
className="hover:bg-primary/90! dark:border-border! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-orange-400! transition-colors"
className="hover:bg-primary/90! border-card! bg-warning! -right-1! h-3! w-3! rounded-full! border-2! transition-colors"
/>
))}
<Handle
@@ -109,7 +121,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
id="else"
isConnectable
style={{ top: getHandleTop(cases.length, totalOutputs) }}
className="hover:bg-primary/90! border-card! -right-1! h-3! w-3! rounded-full! border-2! bg-gray-400! transition-colors!"
className="hover:bg-primary/90! border-card! bg-muted-foreground! -right-1! h-3! w-3! rounded-full! border-2! transition-colors!"
/>
</div>
);
@@ -1,10 +1,14 @@
import { Database } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeProps } from 'reactflow';
import { StateOperationConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
type SetStateNodeData = {
label?: string;
title?: string;
@@ -16,7 +20,8 @@ type SetStateNodeData = {
};
const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
const title = data.title || data.label || 'Set State';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.setState');
const operations = data.config?.operations || [];
const hasLegacy = !operations.length && data.variable;
@@ -25,29 +30,39 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
title={title}
type="state"
selected={selected}
icon={<Database size={16} />}
icon={<Database className="size-4" />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{operations.length > 0 ? (
<div
className="truncate text-xs text-gray-500"
title={`${operations.length} operation(s)`}
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.operationCount', {
count: operations.length,
})}
>
{operations.length} variable{operations.length !== 1 ? 's' : ''}
{t('agents.workflow.nodes.variableCount', {
count: operations.length,
})}
</div>
) : hasLegacy ? (
<>
<div
className="truncate text-xs text-gray-500 uppercase"
title={`Variable: ${data.variable}`}
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.variableTitle', {
...NO_ESCAPE,
name: data.variable,
})}
>
{data.variable}
</div>
{data.value && (
<div
className="truncate text-xs text-blue-600 dark:text-blue-400"
title={`Value: ${data.value}`}
className="text-info truncate text-xs"
title={t('agents.workflow.nodes.valueTitle', {
...NO_ESCAPE,
value: data.value,
})}
>
{data.value}
</div>
+45 -22
View File
@@ -1,25 +1,34 @@
import { Bot, Flag, Play, StickyNote } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { BaseNode } from './BaseNode';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export const StartNode = memo(function StartNode({
selected,
}: {
selected: boolean;
}) {
const { t } = useTranslation();
return (
<BaseNode
title="Start"
title={t('agents.workflow.nodes.start')}
type="start"
selected={selected}
handles={{ target: false, source: true }}
icon={<Play size={16} />}
icon={<Play className="size-4" />}
>
<div className="text-xs text-gray-500">Entry point of the workflow</div>
<div className="text-muted-foreground text-xs">
{t('agents.workflow.nodes.startHint')}
</div>
</BaseNode>
);
});
@@ -29,15 +38,18 @@ export const EndNode = memo(function EndNode({
}: {
selected: boolean;
}) {
const { t } = useTranslation();
return (
<BaseNode
title="End"
title={t('agents.workflow.nodes.end')}
type="end"
selected={selected}
handles={{ target: true, source: false }}
icon={<Flag size={16} />}
icon={<Flag className="size-4" />}
>
<div className="text-xs text-gray-500">Workflow completion</div>
<div className="text-muted-foreground text-xs">
{t('agents.workflow.nodes.endHint')}
</div>
</BaseNode>
);
});
@@ -58,19 +70,20 @@ export const AgentNode = memo(function AgentNode({
};
selected: boolean;
}) {
const title = data.title || data.label || 'Agent';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.agent');
const config = data.config || {};
return (
<BaseNode
title={title}
type="agent"
selected={selected}
icon={<Bot size={16} />}
icon={<Bot className="size-4" />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="truncate text-xs text-gray-500 uppercase"
className="text-muted-foreground truncate text-xs"
title={config.agent_type}
>
{config.agent_type}
@@ -78,7 +91,7 @@ export const AgentNode = memo(function AgentNode({
)}
{config.model_id && (
<div
className="text-primary dark:text-primary truncate text-xs"
className="text-primary truncate text-xs"
title={config.model_id}
>
{config.model_id}
@@ -86,10 +99,16 @@ export const AgentNode = memo(function AgentNode({
)}
{config.output_variable && (
<div
className="truncate text-xs text-gray-500 dark:text-gray-400"
title={`Output: ${config.output_variable}`}
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
Output: {config.output_variable}
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
)}
</div>
@@ -104,7 +123,8 @@ export const NoteNode = memo(function NoteNode({
data: { title?: string; label?: string; content?: string };
selected: boolean;
}) {
const title = data.title || data.label || 'Note';
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.note');
const maxContentLength = 120;
const displayContent =
data.content && data.content.length > maxContentLength
@@ -113,26 +133,29 @@ export const NoteNode = memo(function NoteNode({
return (
<div
className={`max-w-[250px] rounded-3xl border border-yellow-200 bg-yellow-50 px-5 py-3 shadow-md transition-all dark:border-yellow-800 dark:bg-yellow-900/20 ${
className={cn(
// Opaque tint: the card colour underneath, the warning wash painted
// over it as a flat gradient, so the canvas grid doesn't show through.
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition',
selected
? 'scale-105 ring-2 ring-yellow-300 dark:ring-yellow-700'
: 'hover:shadow-lg'
}`}
? 'border-warning ring-warning scale-105 ring-2'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-800/30 dark:text-yellow-500">
<StickyNote size={18} />
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
<StickyNote className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold text-yellow-800 dark:text-yellow-300"
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</div>
{displayContent && (
<div
className="mt-1 text-xs wrap-break-word text-yellow-700 italic dark:text-yellow-400"
className="text-muted-foreground mt-1 text-xs wrap-break-word italic"
title={data.content}
>
{displayContent}
@@ -76,9 +76,9 @@ export function collectCompletedAttachmentIds(
.map((att) => att.id);
}
/** User-facing message shown when a Preview send is blocked. */
/** Locale key of the message shown when a Preview send is blocked. */
export const UNSAVED_DRAFT_ATTACHMENTS_MESSAGE =
'Save the workflow before attaching documents in Preview.';
'agents.workflow.preview.unsavedDraftAttachments';
/**
* Reason a Preview send must be blocked, or null when it may proceed. An
+1 -2
View File
@@ -1,8 +1,7 @@
import { envVar } from '@/env';
import { withThrottle, type FetchLike } from './throttle';
export const baseURL =
envVar('VITE_API_HOST') || 'https://docsapi.arc53.com';
export const baseURL = envVar('VITE_API_HOST') || 'https://docsapi.arc53.com';
const getHeaders = (
token: string | null,
-4
View File
@@ -1,4 +0,0 @@
<svg width="72" height="72" viewBox="0 0 72 72" fill="current" xmlns="http://www.w3.org/2000/svg">
<path d="M33.8788 9.87836C34.4413 9.31595 35.2043 9 35.9998 9C36.7952 9 37.5582 9.31595 38.1208 9.87836L50.1208 21.8784C50.6672 22.4442 50.9696 23.202 50.9628 23.9886C50.9559 24.7752 50.6404 25.5276 50.0842 26.0838C49.528 26.64 48.7755 26.9555 47.989 26.9624C47.2024 26.9692 46.4446 26.6668 45.8788 26.1204L38.9998 19.2414V47.9994C38.9998 48.795 38.6837 49.5581 38.1211 50.1207C37.5585 50.6833 36.7954 50.9994 35.9998 50.9994C35.2041 50.9994 34.441 50.6833 33.8784 50.1207C33.3158 49.5581 32.9998 48.795 32.9998 47.9994V19.2414L26.1208 26.1204C25.555 26.6668 24.7971 26.9692 24.0106 26.9624C23.224 26.9555 22.4715 26.64 21.9153 26.0838C21.3591 25.5276 21.0436 24.7752 21.0367 23.9886C21.0299 23.202 21.3323 22.4442 21.8788 21.8784L33.8788 9.87836Z" fill="#313131"/>
<path d="M18 51C18 50.2044 17.6839 49.4413 17.1213 48.8787C16.5587 48.3161 15.7956 48 15 48C14.2044 48 13.4413 48.3161 12.8787 48.8787C12.3161 49.4413 12 50.2044 12 51V52.8C12 58.446 16.554 63 22.2 63H49.8C55.446 63 60 58.446 60 52.8V51C60 50.2044 59.6839 49.4413 59.1213 48.8787C58.5587 48.3161 57.7957 48 57 48C56.2043 48 55.4413 48.3161 54.8787 48.8787C54.3161 49.4413 54 50.2044 54 51V52.8C54 55.134 52.134 57 49.8 57H22.2C19.866 57 18 55.134 18 52.8V51Z" fill="#313131"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.16669 11.5H9.83335V13.1666H8.16669V11.5ZM8.16669 4.83329H9.83335V9.83329H8.16669V4.83329ZM8.99169 0.666626C4.39169 0.666626 0.666687 4.39996 0.666687 8.99996C0.666687 13.6 4.39169 17.3333 8.99169 17.3333C13.6 17.3333 17.3334 13.6 17.3334 8.99996C17.3334 4.39996 13.6 0.666626 8.99169 0.666626ZM9.00002 15.6666C5.31669 15.6666 2.33335 12.6833 2.33335 8.99996C2.33335 5.31663 5.31669 2.33329 9.00002 2.33329C12.6834 2.33329 15.6667 5.31663 15.6667 8.99996C15.6667 12.6833 12.6834 15.6666 9.00002 15.6666Z" fill="#ECECF1"/>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M440-800v487L216-537l-56 57 320 320 320-320-56-57-224 224v-487h-80Z" />
</svg>

Before

Width:  |  Height:  |  Size: 178 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="14" height="13" viewBox="0 0 14 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033ZM0.5 6.5V7.25H12.5V6.5V5.75H0.5V6.5Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="13" height="23" viewBox="0 0 13 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.3183 20.8523C12.4198 20.9597 12.4991 21.0861 12.5518 21.2242C12.6044 21.3622 12.6294 21.5093 12.6252 21.657C12.621 21.8047 12.5878 21.9502 12.5274 22.0851C12.467 22.2199 12.3807 22.3416 12.2733 22.4431C12.1659 22.5446 12.0395 22.6239 11.9014 22.6766C11.7634 22.7293 11.6163 22.7542 11.4686 22.75C11.3208 22.7459 11.1754 22.7126 11.0405 22.6522C10.9057 22.5919 10.784 22.5055 10.6825 22.3981L1.12001 12.2731C0.922533 12.0642 0.812501 11.7877 0.812501 11.5002C0.812501 11.2128 0.922533 10.9362 1.12001 10.7273L10.6825 0.601207C10.7833 0.49145 10.905 0.40282 11.0403 0.340468C11.1757 0.278115 11.3221 0.243281 11.4711 0.23799C11.62 0.232697 11.7685 0.257054 11.908 0.309644C12.0474 0.362231 12.175 0.442004 12.2834 0.544328C12.3917 0.646653 12.4787 0.769488 12.5392 0.9057C12.5997 1.04191 12.6325 1.18878 12.6357 1.33779C12.639 1.48679 12.6126 1.63495 12.5581 1.77367C12.5036 1.91238 12.4221 2.03889 12.3183 2.14583L3.48476 11.5002L12.3183 20.8523Z" fill="#7D54D1"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="13" height="23" viewBox="0 0 13 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.681741 20.8523C0.580245 20.9597 0.500899 21.0861 0.448232 21.2242C0.395564 21.3622 0.370607 21.5093 0.374785 21.657C0.378964 21.8047 0.412197 21.9502 0.472586 22.0851C0.532974 22.2199 0.619336 22.3416 0.726741 22.4431C0.834146 22.5446 0.96049 22.6239 1.09856 22.6766C1.23663 22.7293 1.38372 22.7542 1.53144 22.75C1.67915 22.7459 1.8246 22.7126 1.95947 22.6522C2.09434 22.5919 2.216 22.5055 2.31749 22.3981L11.88 12.2731C12.0775 12.0642 12.1875 11.7877 12.1875 11.5002C12.1875 11.2128 12.0775 10.9362 11.88 10.7273L2.31749 0.601207C2.21666 0.49145 2.09503 0.40282 1.95967 0.340468C1.8243 0.278115 1.67789 0.243281 1.52895 0.23799C1.38 0.232697 1.23149 0.257054 1.09204 0.309644C0.95259 0.362231 0.824978 0.442004 0.716618 0.544328C0.608256 0.646653 0.521307 0.769488 0.46082 0.9057C0.400333 1.04191 0.367515 1.18878 0.364269 1.33779C0.361025 1.48679 0.387418 1.63495 0.441917 1.77367C0.496417 1.91238 0.577936 2.03889 0.681739 2.14583L9.51524 11.5002L0.681741 20.8523Z" fill="#7D54D1"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.2857 14H2.57143C1.15179 14 0 12.8242 0 11.375V2.625C0 1.17578 1.15179 0 2.57143 0H10.7143C11.4241 0 12 0.587891 12 1.3125V9.1875C12 9.75898 11.6411 10.2457 11.1429 10.4262V12.25C11.617 12.25 12 12.641 12 13.125C12 13.609 11.617 14 11.1429 14H10.2857ZM2.57143 10.5C2.09732 10.5 1.71429 10.891 1.71429 11.375C1.71429 11.859 2.09732 12.25 2.57143 12.25H9.42857V10.5H2.57143ZM3.42857 4.15625C3.42857 4.51992 3.71518 4.8125 4.07143 4.8125H8.78571C9.14196 4.8125 9.42857 4.51992 9.42857 4.15625C9.42857 3.79258 9.14196 3.5 8.78571 3.5H4.07143C3.71518 3.5 3.42857 3.79258 3.42857 4.15625ZM4.07143 6.125C3.71518 6.125 3.42857 6.41758 3.42857 6.78125C3.42857 7.14492 3.71518 7.4375 4.07143 7.4375H8.78571C9.14196 7.4375 9.42857 7.14492 9.42857 6.78125C9.42857 6.41758 9.14196 6.125 8.78571 6.125H4.07143Z" fill="#6A4DF4"/>
</svg>

Before

Width:  |  Height:  |  Size: 930 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 7.5C6 7.36739 5.94732 7.24021 5.85355 7.14645C5.75979 7.05268 5.63261 7 5.5 7H4.5C4.36739 7 4.24021 7.05268 4.14645 7.14645C4.05268 7.24021 4 7.36739 4 7.5V8.5C4 8.63261 4.05268 8.75979 4.14645 8.85355C4.24021 8.94732 4.36739 9 4.5 9H5.5C5.63261 9 5.75979 8.94732 5.85355 8.85355C5.94732 8.75979 6 8.63261 6 8.5V7.5ZM6 10.5C6 10.3674 5.94732 10.2402 5.85355 10.1464C5.75979 10.0527 5.63261 10 5.5 10H4.5C4.36739 10 4.24021 10.0527 4.14645 10.1464C4.05268 10.2402 4 10.3674 4 10.5V11.5C4 11.6326 4.05268 11.7598 4.14645 11.8536C4.24021 11.9473 4.36739 12 4.5 12H5.5C5.63261 12 5.75979 11.9473 5.85355 11.8536C5.94732 11.7598 6 11.6326 6 11.5V10.5ZM7.5 7H8.5C8.63261 7 8.75979 7.05268 8.85355 7.14645C8.94732 7.24021 9 7.36739 9 7.5V8.5C9 8.63261 8.94732 8.75979 8.85355 8.85355C8.75979 8.94732 8.63261 9 8.5 9H7.5C7.36739 9 7.24021 8.94732 7.14645 8.85355C7.05268 8.75979 7 8.63261 7 8.5V7.5C7 7.36739 7.05268 7.24021 7.14645 7.14645C7.24021 7.05268 7.36739 7 7.5 7ZM8.5 10H7.5C7.36739 10 7.24021 10.0527 7.14645 10.1464C7.05268 10.2402 7 10.3674 7 10.5V11.5C7 11.6326 7.05268 11.7598 7.14645 11.8536C7.24021 11.9473 7.36739 12 7.5 12H8.5C8.63261 12 8.75979 11.9473 8.85355 11.8536C8.94732 11.7598 9 11.6326 9 11.5V10.5C9 10.3674 8.94732 10.2402 8.85355 10.1464C8.75979 10.0527 8.63261 10 8.5 10ZM10 7.5C10 7.36739 10.0527 7.24021 10.1464 7.14645C10.2402 7.05268 10.3674 7 10.5 7H11.5C11.6326 7 11.7598 7.05268 11.8536 7.14645C11.9473 7.24021 12 7.36739 12 7.5V8.5C12 8.63261 11.9473 8.75979 11.8536 8.85355C11.7598 8.94732 11.6326 9 11.5 9H10.5C10.3674 9 10.2402 8.94732 10.1464 8.85355C10.0527 8.75979 10 8.63261 10 8.5V7.5Z" fill="#848484"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 0C4.63261 0 4.75979 0.0526784 4.85355 0.146447C4.94732 0.240215 5 0.367392 5 0.5V1H11V0.5C11 0.367392 11.0527 0.240215 11.1464 0.146447C11.2402 0.0526784 11.3674 0 11.5 0C11.6326 0 11.7598 0.0526784 11.8536 0.146447C11.9473 0.240215 12 0.367392 12 0.5V1C13.66 1 15 2.34 15 4V12C15 13.66 13.66 15 12 15H4C2.34 15 1 13.66 1 12V4C1 2.34 2.34 1 4 1V0.5C4 0.367392 4.05268 0.240215 4.14645 0.146447C4.24021 0.0526784 4.36739 0 4.5 0ZM14 4V5H2V4C2 2.9 2.895 2 4 2V2.5C4 2.63261 4.05268 2.75979 4.14645 2.85355C4.24021 2.94732 4.36739 3 4.5 3C4.63261 3 4.75979 2.94732 4.85355 2.85355C4.94732 2.75979 5 2.63261 5 2.5V2H11V2.5C11 2.63261 11.0527 2.75979 11.1464 2.85355C11.2402 2.94732 11.3674 3 11.5 3C11.6326 3 11.7598 2.94732 11.8536 2.85355C11.9473 2.75979 12 2.63261 12 2.5V2C13.1 2 14 2.895 14 4ZM2 12V6H14V12C14 13.1 13.105 14 12 14H4C2.9 14 2 13.105 2 12Z" fill="#848484"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1,3 +0,0 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5C17.523 2.5 22 6.977 22 12.5C22 18.023 17.523 22.5 12 22.5C6.477 22.5 2 18.023 2 12.5C2 6.977 6.477 2.5 12 2.5ZM15.22 9.47L10.75 13.94L8.78 11.97C8.63783 11.8375 8.44978 11.7654 8.25548 11.7688C8.06118 11.7723 7.87579 11.851 7.73838 11.9884C7.60097 12.1258 7.52225 12.3112 7.51883 12.5055C7.5154 12.6998 7.58752 12.8878 7.72 13.03L10.22 15.53C10.3606 15.6705 10.5512 15.7493 10.75 15.7493C10.9488 15.7493 11.1394 15.6705 11.28 15.53L16.28 10.53C16.3537 10.4613 16.4128 10.3785 16.4538 10.2865C16.4948 10.1945 16.5168 10.0952 16.5186 9.99452C16.5204 9.89382 16.5018 9.79379 16.4641 9.7004C16.4264 9.60701 16.3703 9.52218 16.299 9.45096C16.2278 9.37974 16.143 9.3236 16.0496 9.28588C15.9562 9.24816 15.8562 9.22963 15.7555 9.23141C15.6548 9.23318 15.5555 9.25523 15.4635 9.29622C15.3715 9.33721 15.2887 9.39631 15.22 9.47Z" fill="#0C9D35"/>
</svg>

Before

Width:  |  Height:  |  Size: 958 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="11" viewBox="0 0 14 11" fill="none">
<path d="M4.95919 10.1906C4.84318 10.1902 4.72847 10.166 4.62222 10.1194C4.51596 10.0729 4.42041 10.0049 4.34152 9.91985L0.229353 5.54538C0.0756344 5.38157 -0.00671208 5.1634 0.000428491 4.93886C0.00756906 4.71433 0.103612 4.50183 0.267428 4.34812C0.431245 4.1944 0.649417 4.11205 0.873948 4.11919C1.09848 4.12633 1.31098 4.22238 1.4647 4.38619L4.95073 8.10068L12.0666 0.316329C12.1389 0.226405 12.2287 0.152193 12.3306 0.0982513C12.4326 0.0443098 12.5445 0.0117775 12.6594 0.00265255C12.7744 -0.00647237 12.89 0.00800286 12.9992 0.045189C13.1084 0.082375 13.2088 0.141487 13.2943 0.218894C13.3798 0.296301 13.4485 0.390369 13.4964 0.49532C13.5442 0.600272 13.57 0.713891 13.5723 0.829198C13.5746 0.944506 13.5534 1.05907 13.5098 1.16585C13.4662 1.27263 13.4012 1.36937 13.3189 1.45014L5.58533 9.91139C5.50718 9.998 5.41197 10.0675 5.30567 10.1156C5.19938 10.1636 5.0843 10.1892 4.96766 10.1906H4.95919Z" fill="#747474"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg width="16px" height="16px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#11ee1c" stroke="#11ee1c" stroke-width="83.96799999999999"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M866.133333 258.133333L362.666667 761.6l-204.8-204.8L98.133333 618.666667 362.666667 881.066667l563.2-563.2z" fill="#0C9D35"></path></g></svg>

Before

Width:  |  Height:  |  Size: 490 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>

Before

Width:  |  Height:  |  Size: 246 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="7" height="12" viewBox="0 0 7 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.29154 4.88202L1.70154 0.29202C1.60896 0.199438 1.49905 0.125998 1.37808 0.0758932C1.25712 0.0257882 1.12747 -2.37536e-07 0.99654 -2.44235e-07C0.86561 -2.50934e-07 0.735961 0.0257882 0.614997 0.0758931C0.494033 0.125998 0.384122 0.199438 0.29154 0.29202C0.198958 0.384602 0.125519 0.494513 0.0754137 0.615477C0.0253086 0.736441 -0.00048069 0.86609 -0.000480695 0.99702C-0.000480701 1.12795 0.0253086 1.2576 0.0754136 1.37856C0.125519 1.49953 0.198958 1.60944 0.29154 1.70202L4.17154 5.59202L0.29154 9.47202C0.198958 9.5646 0.125518 9.67451 0.0754133 9.79548C0.0253082 9.91644 -0.000481091 10.0461 -0.000481097 10.177C-0.000481102 10.3079 0.0253082 10.4376 0.0754132 10.5586C0.125518 10.6795 0.198958 10.7894 0.29154 10.882C0.384121 10.9746 0.494032 11.048 0.614996 11.0981C0.73596 11.1483 0.865609 11.174 0.99654 11.174C1.12747 11.174 1.25712 11.1483 1.37808 11.0981C1.49905 11.048 1.60896 10.9746 1.70154 10.882L6.29154 6.29202C6.38424 6.19951 6.45779 6.08962 6.50797 5.96864C6.55815 5.84767 6.58398 5.71799 6.58398 5.58702C6.58398 5.45605 6.55815 5.32637 6.50797 5.2054C6.45779 5.08442 6.38424 4.97453 6.29154 4.88202Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(34 197 94)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>

Before

Width:  |  Height:  |  Size: 281 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(239 68 68)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

Before

Width:  |  Height:  |  Size: 293 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.08485 4.07133L3.08485 9.20109C3.08485 9.49263 3.20066 9.77222 3.40681 9.97837C3.61295 10.1845 3.89255 10.3003 4.18408 10.3003C4.47562 10.3003 4.75521 10.1845 4.96136 9.97837C5.1675 9.77222 5.28332 9.49263 5.28332 9.20109L5.28332 3.70492C5.28332 3.12185 5.05169 2.56266 4.6394 2.15037C4.22711 1.73808 3.66792 1.50645 3.08485 1.50645C2.50178 1.50645 1.94259 1.73808 1.5303 2.15037C1.118 2.56266 0.88638 3.12185 0.88638 3.70492L0.886379 9.20109C0.886379 10.0757 1.23381 10.9145 1.85225 11.5329C2.47069 12.1514 3.30948 12.4988 4.18408 12.4988C5.05869 12.4988 5.89747 12.1514 6.51591 11.5329C7.13435 10.9145 7.48178 10.0757 7.48178 9.20109L7.48178 4.07133" stroke="#5D5D5D" stroke-width="1.03637" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 854 B

-11
View File
@@ -1,11 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 16C2.53043 16 3.03914 16.2107 3.41421 16.5858C3.78929 16.9609 4 17.4696 4 18C4 18.5304 3.78929 19.0391 3.41421 19.4142C3.03914 19.7893 2.53043 20 2 20C1.46957 20 0.960859 19.7893 0.585786 19.4142C0.210714 19.0391 0 18.5304 0 18C0 17.4696 0.210714 16.9609 0.585786 16.5858C0.960859 16.2107 1.46957 16 2 16ZM7.5 13C8.16304 13 8.79893 13.2634 9.26777 13.7322C9.73661 14.2011 10 14.837 10 15.5C10 16.163 9.73661 16.7989 9.26777 17.2678C8.79893 17.7366 8.16304 18 7.5 18C6.83696 18 6.20107 17.7366 5.73223 17.2678C5.26339 16.7989 5 16.163 5 15.5C5 14.837 5.26339 14.2011 5.73223 13.7322C6.20107 13.2634 6.83696 13 7.5 13ZM10 0C11.272 0.000250351 12.5033 0.448355 13.4779 1.26572C14.4525 2.08308 15.1082 3.2175 15.33 4.47H15.412C16.4105 4.47 17.3682 4.86667 18.0743 5.57274C18.7803 6.27882 19.177 7.23646 19.177 8.235C19.177 9.23354 18.7803 10.1912 18.0743 10.8973C17.3682 11.6033 16.4105 12 15.412 12H4.588C3.58946 12 2.63182 11.6033 1.92574 10.8973C1.21967 10.1912 0.823 9.23354 0.823 8.235C0.823 7.23646 1.21967 6.27882 1.92574 5.57274C2.63182 4.86667 3.58946 4.47 4.588 4.47H4.67C4.89179 3.2175 5.54749 2.08308 6.52211 1.26572C7.49673 0.448355 8.72801 0.000250351 10 0Z" fill="url(#paint0_linear_6161_11984)"/>
<defs>
<linearGradient id="paint0_linear_6161_11984" x1="0" y1="10" x2="19.177" y2="10" gradientUnits="userSpaceOnUse">
<stop stop-color="#70FDF7"/>
<stop offset="0.325" stop-color="#747696"/>
<stop offset="0.68" stop-color="#BD5372"/>
<stop offset="1" stop-color="#F5A06C"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="13" height="16" stroke-width="1.15" viewBox="0 0 14 17" >
<path d="M13.8013 5.01282L8.80645 0.191795C8.67953 0.0691399 8.50734 0.000152609 8.32774 0H6.09677C5.43801 0 4.80623 0.252586 4.34041 0.702193C3.8746 1.1518 3.6129 1.7616 3.6129 2.39744V3.48718H2.48387C1.82511 3.48718 1.19332 3.73977 0.727509 4.18937C0.261693 4.63898 0 5.24878 0 5.88462V14.6026C0 15.2384 0.261693 15.8482 0.727509 16.2978C1.19332 16.7474 1.82511 17 2.48387 17H8.80645C9.46521 17 10.097 16.7474 10.5628 16.2978C11.0286 15.8482 11.2903 15.2384 11.2903 14.6026V13.5128H11.5161C12.1749 13.5128 12.8067 13.2602 13.2725 12.8106C13.7383 12.361 14 11.7512 14 11.1154V5.44872C13.9929 5.28447 13.9219 5.12884 13.8013 5.01282ZM9.03226 2.23179L11.6877 4.79487H9.03226V2.23179ZM9.93548 14.6026C9.93548 14.8916 9.81653 15.1688 9.6048 15.3731C9.39306 15.5775 9.10589 15.6923 8.80645 15.6923H2.48387C2.18443 15.6923 1.89726 15.5775 1.68552 15.3731C1.47379 15.1688 1.35484 14.8916 1.35484 14.6026V5.88462C1.35484 5.5956 1.47379 5.31842 1.68552 5.11405C1.89726 4.90968 2.18443 4.79487 2.48387 4.79487H3.6129V11.1154C3.6129 11.7512 3.8746 12.361 4.34041 12.8106C4.80623 13.2602 5.43801 13.5128 6.09677 13.5128H9.93548V14.6026ZM11.5161 12.2051H6.09677C5.79734 12.2051 5.51016 12.0903 5.29843 11.886C5.08669 11.6816 4.96774 11.4044 4.96774 11.1154V2.39744C4.96774 2.10842 5.08669 1.83124 5.29843 1.62687C5.51016 1.4225 5.79734 1.30769 6.09677 1.30769H7.67742V5.44872C7.67976 5.62143 7.75188 5.78643 7.87842 5.90856C8.00496 6.03069 8.1759 6.10031 8.35484 6.10256H12.6452V11.1154C12.6452 11.4044 12.5262 11.6816 12.3145 11.886C12.1027 12.0903 11.8156 12.2051 11.5161 12.2051Z" fill="#949494"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

Before

Width:  |  Height:  |  Size: 262 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 0C8.8144 0 10.4902 0.332143 11.739 0.898571C12.362 1.18143 12.9108 1.53643 13.3119 1.96714C13.7172 2.4 14 2.94429 14 3.57143V6.42857C14 7.05571 13.7172 7.59929 13.3119 8.03286C12.9108 8.46357 12.3627 8.81857 11.739 9.10143C10.4902 9.66786 8.8144 10 7 10C5.1856 10 3.5098 9.66786 2.261 9.10143C1.638 8.81857 1.0892 8.46357 0.6881 8.03286C0.2828 7.6 0 7.05571 0 6.42857V3.57143C0 2.94429 0.2828 2.40071 0.6881 1.96714C1.0892 1.53643 1.6373 1.18143 2.261 0.898571C3.5098 0.332143 5.1856 0 7 0ZM12.6 5.77143C12.3375 5.94714 12.047 6.10429 11.739 6.24429C10.4902 6.81071 8.8144 7.14286 7 7.14286C5.1856 7.14286 3.5098 6.81071 2.261 6.24429C1.96243 6.10966 1.67456 5.95157 1.4 5.77143V6.42857C1.4 6.59071 1.47 6.79857 1.7024 7.04857C1.9383 7.30143 2.3128 7.56214 2.8294 7.79643C3.8612 8.26429 5.3354 8.57143 7 8.57143C8.6646 8.57143 10.1388 8.26429 11.1706 7.79643C11.6872 7.56214 12.0617 7.30143 12.2976 7.04857C12.5307 6.79857 12.6 6.59071 12.6 6.42857V5.77143ZM7 1.42857C5.3347 1.42857 3.8612 1.73571 2.8294 2.20357C2.3128 2.43786 1.9383 2.69857 1.7024 2.95143C1.4693 3.20143 1.4 3.40929 1.4 3.57143C1.4 3.73357 1.47 3.94143 1.7024 4.19143C1.9383 4.44429 2.3128 4.705 2.8294 4.93929C3.8612 5.40714 5.3354 5.71429 7 5.71429C8.6646 5.71429 10.1388 5.40714 11.1706 4.93929C11.6872 4.705 12.0617 4.44429 12.2976 4.19143C12.5307 3.94143 12.6 3.73357 12.6 3.57143C12.6 3.40929 12.53 3.20143 12.2976 2.95143C12.0617 2.69857 11.6872 2.43786 11.1706 2.20357C10.1388 1.73643 8.6646 1.42857 7 1.42857Z" fill="#848484"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg width="14" height="14" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
<path d="M6.37776 10.1001V12.9C6.37776 13.457 6.599 13.9911 6.99282 14.3849C7.38664 14.7788 7.92077 15 8.47772 15L11.2777 8.70011V1.00025H3.38181C3.04419 0.996436 2.71656 1.11477 2.45929 1.33344C2.20203 1.55212 2.03246 1.8564 1.98184 2.19023L1.01585 8.49012C0.985398 8.69076 0.998931 8.89563 1.05551 9.09053C1.1121 9.28543 1.21038 9.46569 1.34355 9.61884C1.47671 9.77198 1.64159 9.89434 1.82674 9.97744C2.01189 10.0605 2.2129 10.1024 2.41583 10.1001H6.37776ZM11.2777 1.00025H13.1466C13.5428 0.993247 13.9277 1.13195 14.2284 1.39002C14.5291 1.64809 14.7245 2.00758 14.7776 2.40023V7.30014C14.7245 7.69279 14.5291 8.05227 14.2284 8.31035C13.9277 8.56842 13.5428 8.70712 13.1466 8.70011H11.2777" fill="none"/>
<path d="M11.2777 8.70011L8.47772 15C7.92077 15 7.38664 14.7788 6.99282 14.3849C6.599 13.9911 6.37776 13.457 6.37776 12.9V10.1001H2.41583C2.2129 10.1024 2.01189 10.0605 1.82674 9.97744C1.64159 9.89434 1.47671 9.77198 1.34355 9.61884C1.21038 9.46569 1.1121 9.28543 1.05551 9.09053C0.998931 8.89563 0.985398 8.69076 1.01585 8.49012L1.98184 2.19023C2.03246 1.8564 2.20203 1.55212 2.45929 1.33344C2.71656 1.11477 3.04419 0.996436 3.38181 1.00025H11.2777M11.2777 8.70011V1.00025M11.2777 8.70011H13.1466C13.5428 8.70712 13.9277 8.56842 14.2284 8.31035C14.5291 8.05227 14.7245 7.69279 14.7776 7.30014V2.40023C14.7245 2.00758 14.5291 1.64809 14.2284 1.39002C13.9277 1.13195 13.5428 0.993247 13.1466 1.00025H11.2777" stroke="current" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.9294 5.375V12.4583C12.9294 12.8341 12.7801 13.1944 12.5145 13.4601C12.2488 13.7257 11.8885 13.875 11.5127 13.875H3.01274C2.63701 13.875 2.27668 13.7257 2.011 13.4601C1.74532 13.1944 1.59607 12.8341 1.59607 12.4583V2.54167C1.59607 2.16594 1.74532 1.80561 2.011 1.53993C2.27668 1.27426 2.63701 1.125 3.01274 1.125H8.6794M12.9294 5.375V5.25317C12.9293 4.87747 12.78 4.5172 12.5143 4.25158L9.80282 1.54008C9.53721 1.27439 9.17693 1.12508 8.80124 1.125H8.6794M12.9294 5.375H10.0961C9.72035 5.375 9.36001 5.22574 9.09434 4.96007C8.82866 4.69439 8.6794 4.33406 8.6794 3.95833V1.125" stroke="#949494" stroke-width="1.41667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 781 B

@@ -1,5 +0,0 @@
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.41 10.59L2.83 6L7.41 1.41L6 0L0 6L6 12L7.41 10.59Z" fill="black" fill-opacity="0.54" />
<path d="M15.41 10.59L10.83 6L15.41 1.41L14 0L8 6L14 12L15.41 10.59Z" fill="black"
fill-opacity="0.54" />
</svg>

Before

Width:  |  Height:  |  Size: 324 B

@@ -1,5 +0,0 @@
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.59 10.59L13.17 6L8.59 1.41L10 0L16 6L10 12L8.59 10.59Z" fill="black"
fill-opacity="0.54" />
<path d="M0.59 10.59L5.17 6L0.59 1.41L2 0L8 6L2 12L0.59 10.59Z" fill="black" fill-opacity="0.54" />
</svg>

Before

Width:  |  Height:  |  Size: 322 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#747474" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<path d="M7 10l5 5 5-5"/>
<path d="M12 15V3"/>
</svg>

Before

Width:  |  Height:  |  Size: 291 B

Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff. Show more