feat: dashboard

This commit is contained in:
Sergey Kozyrenko
2026-04-02 00:17:30 +07:00
parent e8d49de9af
commit 53ea23e49e
73 changed files with 4848 additions and 1027 deletions
+2
View File
@@ -17,6 +17,8 @@ node_modules
.cursorrules
.cursorignore
.cursor/
.agents/
skills-lock.json
build/*
data/*
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1.4
# ============================================================
# STEP 1: Build the frontend (Vite → static files)
# ============================================================
FROM node:23-slim AS frontend-builder
ENV NODE_ENV=production
ENV VITE_BUILD_MEMORY_LIMIT=4096
ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /app
RUN apt-get update && apt-get install -y \
ca-certificates \
tzdata \
gcc \
g++ \
make \
git \
&& rm -rf /var/lib/apt/lists/*
COPY ./backend/pkg/graph/schema.graphqls ../backend/pkg/graph/
COPY frontend/ .
# Install dependencies (including devDependencies for the build)
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
# Build frontend with optimizations
RUN npm run build -- \
--mode production \
--minify esbuild \
--outDir dist \
--emptyOutDir \
--sourcemap false \
--target es2020
# ============================================================
# STEP 2: Production image — Nginx (static SPA)
# ============================================================
FROM nginx:stable-alpine
# Copy built frontend static files
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
# Copy Nginx config
COPY nginx.fe.conf /etc/nginx/conf.d/default.conf
# Remove default Nginx site config if it exists
RUN rm -f /etc/nginx/sites-enabled/default
# Copy entrypoint script
COPY entrypoint.fe.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# ---------------------------------------------------------------------------
# Runtime environment variables (all optional, shown with defaults)
# ---------------------------------------------------------------------------
# Nginx
ENV NGINX_PORT=3000
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
# Image Metadata
LABEL org.opencontainers.image.source="https://github.com/vxcontrol/pentagi"
LABEL org.opencontainers.image.description="PentAGI Frontend — Nginx static SPA server"
LABEL org.opencontainers.image.authors="PentAGI Development Team"
LABEL org.opencontainers.image.licenses="MIT License"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# ---------------------------------------------------------------------------
# Configurable environment variables (with defaults)
# ---------------------------------------------------------------------------
NGINX_PORT=${NGINX_PORT:-3000}
# ---------------------------------------------------------------------------
# Patch nginx config with actual port values
# ---------------------------------------------------------------------------
sed -i "s/listen 3000;/listen ${NGINX_PORT};/" /etc/nginx/conf.d/default.conf
# ---------------------------------------------------------------------------
# Start Nginx
# ---------------------------------------------------------------------------
echo "Starting Nginx on port ${NGINX_PORT}..."
exec nginx -g "daemon off;"
+545 -39
View File
@@ -10,10 +10,12 @@
"dependencies": {
"@apollo/client": "^3.13.8",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
@@ -36,6 +38,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.5",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -48,6 +51,7 @@
"lru-cache": "^11.1.0",
"lucide-react": "^0.553.0",
"marked": "^17.0.3",
"monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-day-picker": "^9.13.2",
"react-diff-viewer-continued": "^4.0.6",
@@ -57,13 +61,15 @@
"react-resizable-panels": "^3.0.2",
"react-router-dom": "^7.12.0",
"react-textarea-autosize": "^8.5.9",
"recharts": "^3.7.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^3.25.32"
},
"devDependencies": {
@@ -208,6 +214,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2932,6 +2939,29 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3218,6 +3248,34 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
@@ -4461,6 +4519,42 @@
"@react-pdf/stylesheet": "^6.1.2"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@repeaterjs/repeater": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz",
@@ -4497,9 +4591,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4863,7 +4957,12 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": {
@@ -5528,6 +5627,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -5617,6 +5779,7 @@
"integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -5645,6 +5808,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5655,6 +5819,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5672,6 +5837,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -5728,6 +5899,7 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4",
@@ -6270,7 +6442,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
@@ -6284,6 +6457,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6352,6 +6526,12 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/anser": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz",
"integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==",
"license": "MIT"
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -6739,9 +6919,9 @@
}
},
"node_modules/babel-plugin-macros/node_modules/yaml": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
@@ -6899,6 +7079,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -7577,6 +7758,7 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
@@ -7728,6 +7910,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7859,6 +8162,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -8459,6 +8768,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -8529,6 +8848,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8953,7 +9273,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT"
},
"node_modules/events": {
@@ -9241,9 +9560,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -9670,6 +9989,7 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
@@ -9803,6 +10123,7 @@
"resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.6.tgz",
"integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -10354,6 +10675,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -10501,6 +10832,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -11380,9 +11720,9 @@
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
@@ -13283,9 +13623,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13352,6 +13692,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/monaco-editor/node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14002,11 +14374,12 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14102,6 +14475,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -14273,6 +14647,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14323,6 +14698,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14335,6 +14711,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -14350,7 +14727,8 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -14379,6 +14757,30 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
@@ -14528,6 +14930,52 @@
"node": ">= 6"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14772,6 +15220,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -15448,6 +15902,12 @@
"node": ">=0.1.14"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -15795,16 +16255,9 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -15891,6 +16344,12 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -16050,6 +16509,7 @@
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16064,6 +16524,15 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -16174,6 +16643,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16574,6 +17044,19 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -16616,12 +17099,35 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16762,6 +17268,7 @@
"integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.8",
"@vitest/mocker": "4.0.8",
@@ -17052,6 +17559,7 @@
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -17086,9 +17594,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"bin": {
@@ -17096,9 +17604,6 @@
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-ast-parser": {
@@ -17176,6 +17681,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+8 -2
View File
@@ -20,10 +20,12 @@
"dependencies": {
"@apollo/client": "^3.13.8",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
@@ -46,6 +48,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.5",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -58,6 +61,7 @@
"lru-cache": "^11.1.0",
"lucide-react": "^0.553.0",
"marked": "^17.0.3",
"monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-day-picker": "^9.13.2",
"react-diff-viewer-continued": "^4.0.6",
@@ -67,13 +71,15 @@
"react-resizable-panels": "^3.0.2",
"react-router-dom": "^7.12.0",
"react-textarea-autosize": "^8.5.9",
"recharts": "^3.7.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^3.25.32"
},
"devDependencies": {
+117 -99
View File
@@ -15,16 +15,20 @@ import { FavoritesProvider } from '@/providers/favorites-provider';
import { FlowProvider } from '@/providers/flow-provider';
import { ProvidersProvider } from '@/providers/providers-provider';
import { SidebarFlowsProvider } from '@/providers/sidebar-flows-provider';
import { TemplatesProvider } from '@/providers/templates-provider';
import { ThemeProvider } from '@/providers/theme-provider';
import { UserProvider } from '@/providers/user-provider';
import { SystemSettingsProvider } from './providers/system-settings-provider';
const Dashboard = lazy(() => import('@/pages/dashboard/dashboard'));
const Flow = lazy(() => import('@/pages/flows/flow'));
const FlowReport = lazy(() => import('@/pages/flows/flow-report'));
const Flows = lazy(() => import('@/pages/flows/flows'));
const NewFlow = lazy(() => import('@/pages/flows/new-flow'));
const Login = lazy(() => import('@/pages/login'));
const Template = lazy(() => import('@/pages/templates/template'));
const Templates = lazy(() => import('@/pages/templates/templates'));
const OAuthResult = lazy(() => import('@/pages/oauth-result'));
const SettingsAPITokens = lazy(() => import('@/pages/settings/settings-api-tokens'));
const SettingsPrompt = lazy(() => import('@/pages/settings/settings-prompt'));
@@ -58,70 +62,83 @@ const App = () => {
<BrowserRouter>
<UserProvider>
<FavoritesProvider>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* private routes */}
<Route element={renderProtectedRoute()}>
{/* Main layout for chat pages */}
<Route element={<MainLayout />}>
{/* Flows section with FlowsProvider */}
<Route element={<FlowsLayout />}>
<TemplatesProvider>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* private routes */}
<Route element={renderProtectedRoute()}>
{/* Main layout for chat pages */}
<Route element={<MainLayout />}>
<Route
element={<Flows />}
path="flows"
element={<Dashboard />}
path="dashboard"
/>
{/* Flows section with FlowsProvider */}
<Route element={<FlowsLayout />}>
<Route
element={<Flows />}
path="flows"
/>
<Route
element={<NewFlow />}
path="flows/new"
/>
<Route
element={
<FlowProvider>
<Flow />
</FlowProvider>
}
path="flows/:flowId"
/>
</Route>
<Route
element={<Templates />}
path="templates"
/>
<Route
element={<NewFlow />}
path="flows/new"
/>
<Route
element={
<FlowProvider>
<Flow />
</FlowProvider>
}
path="flows/:flowId"
element={<Template />}
path="templates/:templateId"
/>
</Route>
{/* Other pages can be added here without FlowsProvider */}
</Route>
{/* Settings with nested routes */}
<Route
element={<SettingsLayout />}
path="settings"
>
{/* Settings with nested routes */}
<Route
element={
<Navigate
replace
to="providers"
/>
}
index
/>
<Route
element={<SettingsProviders />}
path="providers"
/>
<Route
element={<SettingsProvider />}
path="providers/:providerId"
/>
<Route
element={<SettingsPrompts />}
path="prompts"
/>
<Route
element={<SettingsPrompt />}
path="prompts/:promptId"
/>
<Route
element={<SettingsAPITokens />}
path="api-tokens"
/>
{/* <Route
element={<SettingsLayout />}
path="settings"
>
<Route
element={
<Navigate
replace
to="providers"
/>
}
index
/>
<Route
element={<SettingsProviders />}
path="providers"
/>
<Route
element={<SettingsProvider />}
path="providers/:providerId"
/>
<Route
element={<SettingsPrompts />}
path="prompts"
/>
<Route
element={<SettingsPrompt />}
path="prompts/:promptId"
/>
<Route
element={<SettingsAPITokens />}
path="api-tokens"
/>
{/* <Route
path="mcp-servers"
element={<SettingsMcpServers />}
/>
@@ -133,53 +150,54 @@ const App = () => {
path="mcp-servers/:mcpServerId"
element={<SettingsMcpServer />}
/> */}
{/* Catch-all route for unknown settings paths */}
<Route
element={
<Navigate
replace
to="/settings/providers"
/>
}
path="*"
/>
{/* Catch-all route for unknown settings paths */}
<Route
element={
<Navigate
replace
to="/settings/providers"
/>
}
path="*"
/>
</Route>
</Route>
</Route>
{/* report routes */}
<Route
element={
<ProtectedRoute>
<SystemSettingsProvider>
<FlowReport />
</SystemSettingsProvider>
</ProtectedRoute>
}
path="flows/:flowId/report"
/>
{/* report routes */}
<Route
element={
<ProtectedRoute>
<SystemSettingsProvider>
<FlowReport />
</SystemSettingsProvider>
</ProtectedRoute>
}
path="flows/:flowId/report"
/>
{/* public routes */}
<Route
element={renderPublicRoute()}
path="login"
/>
{/* public routes */}
<Route
element={renderPublicRoute()}
path="login"
/>
<Route
element={<OAuthResult />}
path="oauth/result"
/>
<Route
element={<OAuthResult />}
path="oauth/result"
/>
{/* other routes */}
<Route
element={<Navigate to="/flows" />}
path="/"
/>
<Route
element={<Navigate to="/flows" />}
path="*"
/>
</Routes>
</Suspense>
{/* other routes */}
<Route
element={<Navigate to="/dashboard" />}
path="/"
/>
<Route
element={<Navigate to="/dashboard" />}
path="*"
/>
</Routes>
</Suspense>
</TemplatesProvider>
</FavoritesProvider>
</UserProvider>
</BrowserRouter>
@@ -1,6 +1,6 @@
import { Outlet } from 'react-router-dom';
import MainSidebar from '@/components/layouts/main-sidebar';
import { MainSidebar } from '@/components/layouts/main-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
const MainLayout = () => {
+104 -258
View File
@@ -2,8 +2,10 @@ import { Avatar, AvatarFallback } from '@radix-ui/react-avatar';
import {
ChevronsUpDown,
Clock,
FileText,
GitFork,
KeyRound,
LayoutDashboard,
LogOut,
Monitor,
Moon,
@@ -15,8 +17,9 @@ import {
UserIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLocation, useMatch, useParams } from 'react-router-dom';
import { Link, useMatch, useParams } from 'react-router-dom';
import type { Flow } from '@/providers/sidebar-flows-provider';
import type { Theme } from '@/providers/theme-provider';
import Logo from '@/components/icons/logo';
@@ -50,18 +53,48 @@ import { useFavorites } from '@/providers/favorites-provider';
import { useSidebarFlows } from '@/providers/sidebar-flows-provider';
import { useUser } from '@/providers/user-provider';
const MainSidebar = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [clickedButtons, setClickedButtons] = useState<Set<string>>(new Set());
interface FlowMenuItemProps {
activeFlowId: null | number;
flow: Flow;
isFavorite: boolean;
onToggleFavorite: (flowId: string) => void;
}
const FlowMenuItem = ({ activeFlowId, flow, isFavorite, onToggleFavorite }: FlowMenuItemProps) => {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={activeFlowId === Number(flow.id)}
>
<Link to={`/flows/${flow.id}`}>
<span className="-mx-2 w-8 shrink-0 text-center text-xs group-data-[state=expanded]:hidden">
{flow.id}
</span>
<span className="text-muted-foreground bg-background dark:bg-muted -my-0.5 -ml-0.5 h-5 min-w-5 shrink-0 rounded-md px-px py-0.5 text-center text-xs group-data-[state=collapsed]:hidden">
{flow.id}
</span>
<span className="truncate">{flow.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className="data-[state=open]:bg-accent rounded-sm"
onClick={() => onToggleFavorite(flow.id)}
showOnHover
>
<Star className={isFavorite ? 'fill-yellow-500 stroke-yellow-500' : ''} />
</SidebarMenuAction>
</SidebarMenuItem>
);
};
export const MainSidebar = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const isDashboardActive = useMatch('/dashboard');
const isFlowsActive = useMatch('/flows/*');
const isTemplatesActive = useMatch('/templates/*');
const isSettingsActive = useMatch('/settings/*');
const { flowId: flowIdParam } = useParams<{ flowId: string }>();
const location = useLocation();
// Flows button is active only on /flows list and /flows/new, not on specific flow pages
const isFlowsActive = useMemo(() => {
return location.pathname === '/flows' || location.pathname === '/flows/new';
}, [location.pathname]);
const { authInfo, logout } = useUser();
const user = authInfo?.user;
@@ -69,64 +102,24 @@ const MainSidebar = () => {
const { addFavoriteFlow, favoriteFlowIds, removeFavoriteFlow } = useFavorites();
const { flows } = useSidebarFlows();
// Convert flowId to number for comparison
const flowId = useMemo(() => {
return flowIdParam ? +flowIdParam : null;
}, [flowIdParam]);
const flowId = useMemo(() => (flowIdParam ? Number(flowIdParam) : null), [flowIdParam]);
// Check if we're on a specific flow page (not /flows/new)
const isOnFlowPage = useMemo(() => {
return location.pathname.startsWith('/flows/') && flowIdParam && flowIdParam !== 'new';
}, [location.pathname, flowIdParam]);
const favoriteFlows = useMemo(
() =>
flows
.filter((flow) => favoriteFlowIds.includes(Number(flow.id)))
.sort((a, b) => Number(b.id) - Number(a.id)),
[flows, favoriteFlowIds],
);
// Get favorite flows (full objects)
const favoriteFlows = useMemo(() => {
const filtered = flows
.filter((flow) => {
const numericFlowId = typeof flow.id === 'string' ? +flow.id : flow.id;
return favoriteFlowIds.includes(numericFlowId);
})
.sort((a, b) => +b.id - +a.id);
return filtered;
}, [flows, favoriteFlowIds]);
// Get recent flows (5 latest non-favorites, sorted by createdAt desc)
const recentFlows = useMemo(() => {
const nonFavoriteFlows = flows.filter((flow) => {
const numericFlowId = typeof flow.id === 'string' ? +flow.id : flow.id;
return !favoriteFlowIds.includes(numericFlowId);
});
const sortedByDate = [...nonFavoriteFlows].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return sortedByDate.slice(0, 5);
}, [flows, favoriteFlowIds]);
// Get current flow (if on flow page and not in recent/favorites)
const currentFlow = useMemo(() => {
if (!isOnFlowPage || !flowId) {
return null;
}
const isInRecent = recentFlows.some((flow) => +flow.id === flowId);
const isInFavorites = favoriteFlows.some((flow) => +flow.id === flowId);
if (isInRecent || isInFavorites) {
return null;
}
const found = flows.find((flow) => +flow.id === flowId) || null;
return found;
}, [isOnFlowPage, flowId, flows, recentFlows, favoriteFlows]);
const handlePasswordChangeSuccess = () => {
setIsPasswordModalOpen(false);
};
const recentFlows = useMemo(
() =>
flows
.filter((flow) => !favoriteFlowIds.includes(Number(flow.id)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[flows, favoriteFlowIds],
);
return (
<Sidebar collapsible="icon">
@@ -154,6 +147,17 @@ const MainSidebar = () => {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isDashboardActive}
>
<Link to="/dashboard">
<LayoutDashboard />
Dashboard
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
@@ -174,78 +178,30 @@ const MainSidebar = () => {
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isTemplatesActive}
>
<Link to="/templates">
<FileText />
Templates
</Link>
</SidebarMenuButton>
<SidebarMenuAction
asChild
className="data-[state=open]:bg-accent rounded-sm"
showOnHover
>
<Link to="/templates/new">
<Plus />
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{currentFlow && (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem
onMouseLeave={(e) => {
const menuItem = e.currentTarget;
menuItem.querySelectorAll('button, a').forEach((el) => {
if (el instanceof HTMLElement) {
el.blur();
}
});
const key = `current-${currentFlow.id}`;
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}}
>
<SidebarMenuButton
asChild
isActive={true}
>
<Link to={`/flows/${currentFlow.id}`}>
<span className="-mx-2 w-8 shrink-0 text-center text-xs group-data-[state=expanded]:hidden">
{currentFlow.id}
</span>
<span className="text-muted-foreground bg-background dark:bg-muted -my-0.5 -ml-0.5 h-5 min-w-5 shrink-0 rounded-md px-px py-0.5 text-center text-xs group-data-[state=collapsed]:hidden">
{currentFlow.id}
</span>
<span className="truncate">{currentFlow.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className={`data-[state=open]:bg-accent rounded-sm ${clickedButtons.has(`current-${currentFlow.id}`) ? 'pointer-events-none! opacity-0!' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const button = e.currentTarget;
button.blur();
const key = `current-${currentFlow.id}`;
setClickedButtons((prev) => new Set(prev).add(key));
addFavoriteFlow(currentFlow.id);
setTimeout(() => {
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 600);
}}
showOnHover
>
<Star />
</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{recentFlows.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-2">
@@ -255,65 +211,13 @@ const MainSidebar = () => {
<SidebarGroupContent>
<SidebarMenu>
{recentFlows.map((flow) => (
<SidebarMenuItem
<FlowMenuItem
activeFlowId={flowId}
flow={flow}
isFavorite={false}
key={flow.id}
onMouseLeave={(e) => {
const menuItem = e.currentTarget;
menuItem.querySelectorAll('button, a').forEach((el) => {
if (el instanceof HTMLElement) {
el.blur();
}
});
const key = `recent-${flow.id}`;
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}}
>
<SidebarMenuButton
asChild
isActive={flowId === +flow.id}
>
<Link to={`/flows/${flow.id}`}>
<span className="-mx-2 w-8 shrink-0 text-center text-xs group-data-[state=expanded]:hidden">
{flow.id}
</span>
<span className="text-muted-foreground bg-background dark:bg-muted -my-0.5 -ml-0.5 h-5 min-w-5 shrink-0 rounded-md px-px py-0.5 text-center text-xs group-data-[state=collapsed]:hidden">
{flow.id}
</span>
<span className="truncate">{flow.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className={`data-[state=open]:bg-accent rounded-sm ${clickedButtons.has(`recent-${flow.id}`) ? 'pointer-events-none! opacity-0!' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const button = e.currentTarget;
button.blur();
const key = `recent-${flow.id}`;
setClickedButtons((prev) => new Set(prev).add(key));
addFavoriteFlow(flow.id);
setTimeout(() => {
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 600);
}}
showOnHover
>
<Star />
</SidebarMenuAction>
</SidebarMenuItem>
onToggleFavorite={addFavoriteFlow}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
@@ -329,65 +233,13 @@ const MainSidebar = () => {
<SidebarGroupContent>
<SidebarMenu>
{favoriteFlows.map((flow) => (
<SidebarMenuItem
<FlowMenuItem
activeFlowId={flowId}
flow={flow}
isFavorite
key={flow.id}
onMouseLeave={(e) => {
const menuItem = e.currentTarget;
menuItem.querySelectorAll('button, a').forEach((el) => {
if (el instanceof HTMLElement) {
el.blur();
}
});
const key = `favorite-${flow.id}`;
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}}
>
<SidebarMenuButton
asChild
isActive={flowId === +flow.id}
>
<Link to={`/flows/${flow.id}`}>
<span className="-mx-2 w-8 shrink-0 text-center text-xs group-data-[state=expanded]:hidden">
{flow.id}
</span>
<span className="text-muted-foreground bg-background dark:bg-muted -my-0.5 -ml-0.5 h-5 min-w-5 shrink-0 rounded-md px-px py-0.5 text-center text-xs group-data-[state=collapsed]:hidden">
{flow.id}
</span>
<span className="truncate">{flow.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className={`data-[state=open]:bg-accent rounded-sm ${clickedButtons.has(`favorite-${flow.id}`) ? 'pointer-events-none! opacity-0!' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const button = e.currentTarget;
button.blur();
const key = `favorite-${flow.id}`;
setClickedButtons((prev) => new Set(prev).add(key));
removeFavoriteFlow(flow.id);
setTimeout(() => {
setClickedButtons((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 600);
}}
showOnHover
>
<Star className="fill-yellow-500 stroke-yellow-500" />
</SidebarMenuAction>
</SidebarMenuItem>
onToggleFavorite={removeFavoriteFlow}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
@@ -415,10 +267,6 @@ const MainSidebar = () => {
size="lg"
>
<Avatar className="bg-background dark:bg-muted size-8 rounded-lg">
{/* <AvatarImage
alt={user.name}
src={user.avatar}
/> */}
<AvatarFallback className="flex size-8 items-center justify-center">
<UserIcon className="size-4" />
</AvatarFallback>
@@ -508,7 +356,7 @@ const MainSidebar = () => {
<SidebarRail />
<Dialog
onOpenChange={(open) => setIsPasswordModalOpen(open)}
onOpenChange={setIsPasswordModalOpen}
open={isPasswordModalOpen}
>
<DialogContent className="sm:max-w-[425px]">
@@ -517,12 +365,10 @@ const MainSidebar = () => {
</DialogHeader>
<PasswordChangeForm
onCancel={() => setIsPasswordModalOpen(false)}
onSuccess={handlePasswordChangeSuccess}
onSuccess={() => setIsPasswordModalOpen(false)}
/>
</DialogContent>
</Dialog>
</Sidebar>
);
};
export default MainSidebar;
+1 -1
View File
@@ -238,7 +238,7 @@ const Markdown = ({ children, className, searchValue }: MarkdownProps) => {
}, [processedSearch, createComponentRenderer]);
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className || ''}`}>
<div className={`prose prose-sm dark:prose-invert max-w-none ${className || ''}`}>
<ReactMarkdown
components={customComponents}
rehypePlugins={[
@@ -0,0 +1,570 @@
/**
* Monaco Terminal Component
*
* High-performance terminal log viewer based on Monaco Editor with ANSI color support.
* Uses the `anser` library for robust ANSI escape sequence parsing.
*
* Supported ANSI features (via anser):
* - SGR codes: Reset, Bold, Dim, Italic, Underline, Blink, Reverse, Hidden, Strikethrough
* - Standard and bright foreground/background colors (30-37, 40-47, 90-97, 100-107)
* - 256-color palette (38;5;N, 48;5;N)
* - True color / 24-bit RGB (38;2;R;G;B, 48;2;R;G;B)
* - Reverse video (proper fg/bg color swap handled by anser)
*
* Key optimizations:
* - Dynamic CSS class injection with deduplication for color support
* - IEditorDecorationsCollection for efficient decoration management
* - Module-level WeakMap cache for parsed logs (avoids re-parsing same logs array)
* - Memoized content extraction for better React performance
* - Incremental decoration updates (only decorates new lines)
* - Stable useCallback for mount handler
*
* @see https://microsoft.github.io/monaco-editor/docs.html for Monaco API reference
* @see https://github.com/IonicaBizau/anser for ANSI parser documentation
*/
import type * as monaco from 'monaco-editor';
import Editor, { type Monaco, type OnMount } from '@monaco-editor/react';
import Anser from 'anser';
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useTheme } from '@/hooks/use-theme';
import { Log } from '@/lib/log';
import { cn } from '@/lib/utils';
interface MonacoTerminalProps {
className?: string;
logs: string[];
searchValue?: string;
}
interface MonacoTerminalRef {
findNext: () => void;
findPrevious: () => void;
}
interface ParsedLine {
lineNumber: number;
segments: ParsedSegment[];
text: string;
}
interface ParsedSegment {
className: string;
endColumn: number;
startColumn: number;
}
/**
* Set of already injected CSS class names to prevent duplicate style injection.
*/
const injectedColorClasses = new Set<string>();
/**
* Converts an RGB string like "187, 0, 0" to a hex string "bb0000".
*/
const rgbStringToHex = (rgb: string): string =>
rgb
.split(',')
.map((part) => Math.min(255, Math.max(0, parseInt(part.trim(), 10))).toString(16).padStart(2, '0'))
.join('');
/**
* Returns the shared style element for dynamic color injection, creating it if needed.
*/
const getDynamicStyleElement = (): HTMLStyleElement => {
const styleId = 'monaco-terminal-dynamic-colors';
let element = document.getElementById(styleId) as HTMLStyleElement | null;
if (!element) {
element = document.createElement('style');
element.id = styleId;
document.head.appendChild(element);
}
return element;
};
/**
* Ensures a CSS class for the given RGB color exists, injecting it if needed.
* Returns the class name.
*/
const ensureColorClass = (prefix: string, rgb: string, cssProperty: string): string => {
const hex = rgbStringToHex(rgb);
const className = `${prefix}-${hex}`;
if (!injectedColorClasses.has(className)) {
getDynamicStyleElement().textContent += `.${className} { ${cssProperty}: rgb(${rgb}) !important; }\n`;
injectedColorClasses.add(className);
}
return className;
};
/**
* Maps anser decoration names to CSS class names.
*/
const DECORATION_CLASS_MAP: Record<string, string> = {
blink: 'ansi-blink',
bold: 'ansi-bold',
dim: 'ansi-dim',
hidden: 'ansi-hidden',
italic: 'ansi-italic',
strikethrough: 'ansi-strikethrough',
underline: 'ansi-underline',
};
/**
* Static CSS for text decoration and font style classes.
*/
const DECORATION_STYLES = [
'.ansi-bold { font-weight: bold !important; }',
'.ansi-dim { opacity: 0.7 !important; }',
'.ansi-italic { font-style: italic !important; }',
'.ansi-underline { text-decoration: underline !important; }',
'.ansi-strikethrough { text-decoration: line-through !important; }',
'.ansi-underline.ansi-strikethrough { text-decoration: underline line-through !important; }',
'.ansi-hidden { visibility: hidden !important; }',
'.ansi-blink { animation: ansi-blink 1s step-end infinite !important; }',
'@keyframes ansi-blink { 50% { opacity: 0; } }',
].join('\n');
/**
* Regex matching ANSI sequences and control characters not handled by anser.
* Anser only processes CSI SGR (ESC[...m). This regex matches everything else:
* - OSC sequences: ESC ] ... (BEL | ESC \)
* - DCS sequences: ESC P ... (ESC \)
* - Character set designations: ESC ( X, ESC ) X
* - DEC private sequences: ESC # N
* - Single-character escape codes: ESC followed by 7,8,D,M,E,H,c,N,O,Z,=,>,<
* - Lone ESC not followed by [ (catch-all for any remaining non-CSI escape)
* - Non-printable control characters (except \t and \n)
*/
const UNSUPPORTED_SEQUENCES_REGEX = new RegExp(
[
'\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)', // OSC: ESC ] ... (BEL | ST)
'\\x1bP[\\s\\S]*?\\x1b\\\\', // DCS: ESC P ... ST
'\\x1b[()][A-Z0-9]', // Character set: ESC ( X or ESC ) X
'\\x1b#[0-9]', // DEC screen alignment: ESC # N
'\\x1b[78DMEHcNOZ=>]', // Single-char escape sequences
'\\x1b(?!\\[)', // Lone ESC not starting a CSI sequence
'[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1a\\x1c-\\x1f\\x7f]', // Control chars (keep \t=0x09, \n=0x0a, \r=0x0d; skip ESC=0x1b)
].join('|'),
'g',
);
/**
* Sanitizes a single line before passing to anser:
* 1. Handles carriage returns (\r) — simulates terminal overwrite behavior
* by keeping only content after the last \r on the line
* 2. Strips non-SGR escape sequences and control characters
*/
const sanitizeTerminalLine = (line: string): string => {
// Handle carriage returns: keep only content after the last lone \r
// This simulates terminal overwrite (e.g. progress bars)
const lastCarriageReturn = line.lastIndexOf('\r');
const withoutCarriageReturns = lastCarriageReturn !== -1 ? line.substring(lastCarriageReturn + 1) : line;
return withoutCarriageReturns.replace(UNSUPPORTED_SEQUENCES_REGEX, '');
};
/**
* Parses a single line using anser and returns structured data for Monaco decorations.
* Anser handles all ANSI SGR codes including 256-color, true color, and reverse video.
* Non-SGR sequences and control characters are stripped before parsing.
*/
const parseAnsiLine = (line: string, lineNumber: number): ParsedLine => {
if (!line) {
return { lineNumber, segments: [], text: '' };
}
const sanitizedLine = sanitizeTerminalLine(line);
if (!sanitizedLine) {
return { lineNumber, segments: [], text: '' };
}
const entries = Anser.ansiToJson(sanitizedLine, { remove_empty: true });
const segments: ParsedSegment[] = [];
let column = 1;
for (const entry of entries) {
if (!entry.content) {
continue;
}
const startColumn = column;
const endColumn = column + entry.content.length;
column = endColumn;
const classNames: string[] = [];
if (entry.fg) {
classNames.push(ensureColorClass('ansi-fg', entry.fg, 'color'));
}
if (entry.bg) {
classNames.push(ensureColorClass('ansi-bg', entry.bg, 'background-color'));
}
for (const decoration of entry.decorations) {
const decorationClass = DECORATION_CLASS_MAP[decoration];
if (decorationClass) {
classNames.push(decorationClass);
}
}
if (classNames.length) {
segments.push({
className: classNames.join(' '),
endColumn,
startColumn,
});
}
}
const text = entries.map((entry) => entry.content).join('');
return { lineNumber, segments, text };
};
/**
* Module-level cache for parsed lines.
* Uses WeakMap to avoid memory leaks - when logs array is garbage collected, cache is cleaned.
*/
const parsedLogsCache = new WeakMap<readonly string[], ParsedLine[]>();
/**
* Parse logs array with caching.
* Returns cached result if the same logs array reference is passed.
*/
const parseLogsWithCache = (logs: string[]): ParsedLine[] => {
const cached = parsedLogsCache.get(logs);
if (cached) {
return cached;
}
const allLogsText = logs.join('\n');
const lines = allLogsText.split('\n');
const parsedLines = lines.map((line, index) => parseAnsiLine(line, index + 1));
parsedLogsCache.set(logs, parsedLines);
return parsedLines;
};
/**
* Terminal component based on Monaco Editor with ANSI color support.
* Provides a read-only code viewer with search functionality, theme support, and ANSI color rendering.
* Compatible with the existing Terminal component API.
*/
const MonacoTerminal = ({
className,
logs,
ref,
searchValue,
}: MonacoTerminalProps & { ref?: React.RefObject<MonacoTerminalRef | null> }) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const { theme } = useTheme();
const [isEditorReady, setIsEditorReady] = useState(false);
const prevLogsLengthRef = useRef<number>(0);
const searchWidgetRef = useRef<null | { findNext: () => void; findPrevious: () => void }>(null);
const decorationsCollectionRef = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
// Parse ANSI codes from logs with module-level caching
const parsedContent = useMemo(() => parseLogsWithCache(logs), [logs]);
// Memoized plain text content extraction
const content = useMemo(() => parsedContent.map((line) => line.text).join('\n'), [parsedContent]);
// Monaco editor mount handler - wrapped in useCallback for stability
const handleEditorDidMount: OnMount = useCallback((editor, monacoInstance) => {
editorRef.current = editor;
monacoRef.current = monacoInstance;
setIsEditorReady(true);
// Configure editor for optimal performance and word wrapping
editor.updateOptions({
automaticLayout: true,
folding: false,
glyphMargin: false,
lineDecorationsWidth: 0,
lineNumbers: 'on',
lineNumbersMinChars: 5,
minimap: { enabled: false },
overviewRulerLanes: 0, // Disable overview ruler for better performance
padding: { top: 4 },
readOnly: true,
renderLineHighlight: 'none',
renderWhitespace: 'none',
scrollbar: {
alwaysConsumeMouseWheel: false,
horizontal: 'hidden', // Hide horizontal scrollbar completely
useShadows: false,
vertical: 'visible',
verticalScrollbarSize: 10,
},
scrollBeyondLastLine: false,
selectOnLineNumbers: false,
wordWrap: 'on', // Enable word wrap (simple 'on' works better than 'bounded')
wrappingIndent: 'same',
wrappingStrategy: 'simple', // Use simple strategy for better performance
});
// Inject ANSI decoration CSS styles once (static set of classes)
const ansiStyleId = 'monaco-terminal-ansi-styles';
if (!document.getElementById(ansiStyleId)) {
const ansiStyleElement = document.createElement('style');
ansiStyleElement.id = ansiStyleId;
ansiStyleElement.textContent = DECORATION_STYLES;
document.head.appendChild(ansiStyleElement);
}
// Inject padding and background styles
const terminalStyleId = 'monaco-terminal-custom-styles';
if (!document.getElementById(terminalStyleId)) {
const terminalStyleElement = document.createElement('style');
terminalStyleElement.id = terminalStyleId;
terminalStyleElement.textContent = `
.monaco-editor .line-numbers {
padding-right: 12px !important;
}
.monaco-editor,
.monaco-editor .monaco-editor-background,
.monaco-editor .margin {
background-color: var(--background) !important;
}
`;
document.head.appendChild(terminalStyleElement);
}
// Store search widget reference
searchWidgetRef.current = {
findNext: () => {
try {
editor.trigger('monaco-terminal', 'actions.find', {});
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
} catch (error: unknown) {
Log.error('Monaco findNext failed:', error);
}
},
findPrevious: () => {
try {
editor.trigger('monaco-terminal', 'actions.find', {});
editor.trigger('monaco-terminal', 'editor.action.previousMatchFindAction', {});
} catch (error: unknown) {
Log.error('Monaco findPrevious failed:', error);
}
},
};
}, []);
// Expose methods to parent component via ref
useImperativeHandle(
ref,
() => ({
findNext: () => {
if (searchWidgetRef.current && editorRef.current) {
searchWidgetRef.current.findNext();
}
},
findPrevious: () => {
if (searchWidgetRef.current && editorRef.current) {
searchWidgetRef.current.findPrevious();
}
},
}),
[],
);
// Cache for tracking which lines have been decorated
const decoratedLinesCountRef = useRef<number>(0);
// Apply ANSI color decorations incrementally
useEffect(() => {
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
return;
}
const editor = editorRef.current;
const monacoInstance = monacoRef.current;
try {
const decoratedCount = decoratedLinesCountRef.current;
// If content was cleared or reduced, reset decorations
if (parsedContent.length < decoratedCount) {
if (decorationsCollectionRef.current) {
decorationsCollectionRef.current.clear();
}
decoratedLinesCountRef.current = 0;
}
// If no new lines to decorate, skip
if (parsedContent.length <= decoratedLinesCountRef.current) {
return;
}
// Build decorations only for new lines (incremental)
const newDecorations: monaco.editor.IModelDeltaDecoration[] = [];
for (let index = decoratedLinesCountRef.current; index < parsedContent.length; index++) {
const line = parsedContent[index];
if (!line) {
continue;
}
for (const segment of line.segments) {
newDecorations.push({
options: {
inlineClassName: segment.className,
},
range: new monacoInstance.Range(
line.lineNumber,
segment.startColumn,
line.lineNumber,
segment.endColumn,
),
});
}
}
// Apply new decorations incrementally
if (newDecorations.length) {
if (decorationsCollectionRef.current) {
// Append new decorations to existing collection
decorationsCollectionRef.current.append(newDecorations);
} else {
// Create new collection if it doesn't exist
decorationsCollectionRef.current = editor.createDecorationsCollection(newDecorations);
}
}
// Update decorated lines count
decoratedLinesCountRef.current = parsedContent.length;
} catch (error: unknown) {
Log.error('Monaco apply decorations failed:', error);
}
}, [parsedContent, isEditorReady]);
// Auto-scroll to bottom when new logs are added
useEffect(() => {
if (!isEditorReady || !editorRef.current) {
return;
}
const editor = editorRef.current;
// Only scroll if new logs were added (not on initial render or when logs were cleared)
if (logs.length > prevLogsLengthRef.current && prevLogsLengthRef.current > 0) {
try {
const lineCount = editor.getModel()?.getLineCount() ?? 0;
if (lineCount > 0) {
// Scroll to the last line (1 = Smooth scroll type)
editor.revealLine(lineCount, 1);
}
} catch (error: unknown) {
Log.error('Monaco scroll failed:', error);
}
}
prevLogsLengthRef.current = logs.length;
}, [logs, isEditorReady]);
// Handle search functionality
useEffect(() => {
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
return;
}
const editor = editorRef.current;
if (searchValue?.trim()) {
try {
// Use Monaco's built-in find functionality
const searchText = searchValue.trim();
// Create find options
const findOptions = {
isRegex: false,
matchCase: false,
preserveCase: false,
searchString: searchText,
wholeWord: false,
};
// Trigger find action with the search string
editor.trigger('monaco-terminal', 'actions.find', findOptions);
// Automatically jump to first match using requestAnimationFrame
// for better timing than arbitrary setTimeout delays
requestAnimationFrame(() => {
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
});
} catch (error: unknown) {
Log.error('Monaco search failed:', error);
}
} else {
// Close find widget when search is cleared
try {
editor.trigger('monaco-terminal', 'closeFindWidget', {});
} catch (error: unknown) {
Log.error('Monaco close find widget failed:', error);
}
}
}, [searchValue, isEditorReady]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Clear decorations collection
if (decorationsCollectionRef.current) {
decorationsCollectionRef.current.clear();
decorationsCollectionRef.current = null;
}
// Reset incremental decorations counter
decoratedLinesCountRef.current = 0;
// Note: We don't remove shared styles as they persist across instances
// parsedLogsCache uses WeakMap so it's automatically cleaned up
};
}, []);
return (
<div className={cn('relative size-full overflow-hidden', className)}>
<Editor
defaultLanguage="plaintext"
height="100%"
language="plaintext"
onMount={handleEditorDidMount}
options={{
domReadOnly: true,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
fontWeight: '600',
}}
theme={theme === 'dark' ? 'vs-dark' : 'vs-light'}
value={content}
width="100%"
/>
</div>
);
};
MonacoTerminal.displayName = 'MonacoTerminal';
export default MonacoTerminal;
+3 -3
View File
@@ -32,7 +32,7 @@ const AccordionTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
@@ -43,11 +43,11 @@ const AccordionContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ children, className, ...props }, ref) => (
<AccordionPrimitive.Content
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
ref={ref}
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+1 -1
View File
@@ -34,7 +34,7 @@ Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
ref={ref}
{...props}
/>
+1 -1
View File
@@ -32,7 +32,7 @@ const AvatarFallback = React.forwardRef<
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
className={cn('bg-muted flex h-full w-full items-center justify-center rounded-full', className)}
ref={ref}
{...props}
/>
+3 -3
View File
@@ -22,7 +22,7 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
({ className, ...props }, ref) => (
<ol
className={cn(
'flex flex-wrap items-center gap-1.5 wrap-break-word text-sm text-muted-foreground sm:gap-2.5',
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5',
className,
)}
ref={ref}
@@ -53,7 +53,7 @@ const BreadcrumbLink = React.forwardRef<
return (
<Comp
className={cn('transition-colors hover:text-foreground', className)}
className={cn('hover:text-foreground transition-colors', className)}
ref={ref}
{...props}
/>
@@ -66,7 +66,7 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
<span
aria-current="page"
aria-disabled="true"
className={cn('font-normal text-foreground', className)}
className={cn('text-foreground font-normal', className)}
ref={ref}
role="link"
{...props}
+9 -9
View File
@@ -12,7 +12,7 @@ const Command = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
ref={ref}
@@ -25,7 +25,7 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="**:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 **:[[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<Command className="**:[[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
@@ -41,7 +41,7 @@ const CommandInput = React.forwardRef<
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
@@ -57,7 +57,7 @@ const CommandList = React.forwardRef<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
ref={ref}
{...props}
/>
@@ -70,7 +70,7 @@ const CommandEmpty = React.forwardRef<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
className="py-6 text-center text-sm text-muted-foreground/50"
className="text-muted-foreground/50 py-6 text-center text-sm"
ref={ref}
{...props}
/>
@@ -84,7 +84,7 @@ const CommandGroup = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
className={cn(
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
className,
)}
ref={ref}
@@ -99,7 +99,7 @@ const CommandSeparator = React.forwardRef<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
className={cn('-mx-1 h-px bg-border', className)}
className={cn('bg-border -mx-1 h-px', className)}
ref={ref}
{...props}
/>
@@ -112,7 +112,7 @@ const CommandItem = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
ref={ref}
@@ -125,7 +125,7 @@ CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
+188
View File
@@ -0,0 +1,188 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ children, className, inset, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && 'pl-8',
className,
)}
ref={ref}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-context-menu-content-transform-origin] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
ref={ref}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] origin-[--radix-context-menu-content-transform-origin] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
ref={ref}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
inset && 'pl-8',
className,
)}
ref={ref}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ checked, children, className, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
ref={ref}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ children, className, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
ref={ref}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
className={cn('text-foreground px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
ref={ref}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
className={cn('bg-border -mx-1 my-1 h-px', className)}
ref={ref}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
};
+248 -195
View File
@@ -1,5 +1,3 @@
'use client';
import {
type ColumnDef,
type ColumnFiltersState,
@@ -10,14 +8,16 @@ import {
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type Row,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Fragment, type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -27,14 +27,17 @@ import {
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
// Extend ColumnMeta interface from @tanstack/react-table
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
cellClassName?: string;
headerClassName?: string;
}
}
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import { getColumnStorageKey, getPageStorageKey, getSortingStorageKey } from '@/lib/storage-keys';
import {
loadColumnVisibility,
loadPageState,
loadSorting,
saveColumnVisibility,
savePageState,
saveSorting,
} from '@/lib/table-storage';
import { cn } from '@/lib/utils';
interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
@@ -43,65 +46,78 @@ interface DataTableProps<TData, TValue = unknown> {
filterColumn?: string;
filterPlaceholder?: string;
initialPageSize?: number;
initialSorting?: SortingState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
onPageChange?: (pageIndex: number) => void;
onRowClick?: (row: TData) => void;
pageIndex?: number;
renderSubComponent?: (props: { row: unknown }) => React.ReactElement;
tableKey?: string;
renderRowContextMenu?: (row: TData) => ReactNode;
renderSubComponent?: (props: { row: Row<TData> }) => ReactElement;
}
const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100] as const;
function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
const {
columns,
columnVisibility: externalColumnVisibility,
data,
filterColumn = 'name',
filterPlaceholder = 'Filter...',
initialPageSize = 10,
onColumnVisibilityChange,
onPageChange,
onRowClick,
pageIndex,
renderSubComponent,
tableKey,
} = props;
function DataTable<TData, TValue = unknown>({
columns,
columnVisibility: externalColumnVisibility,
data,
filterColumn = 'name',
filterPlaceholder = 'Filter...',
initialPageSize = 10,
initialSorting = [],
onColumnVisibilityChange,
onPageChange,
onRowClick,
pageIndex: externalPageIndex,
renderRowContextMenu,
renderSubComponent,
}: DataTableProps<TData, TValue>) {
const isColumnVisibilityControlled = externalColumnVisibility !== undefined;
const isPageControlled = externalPageIndex !== undefined;
const isRowInteractive = !!onRowClick || !!renderSubComponent;
// Load page size from localStorage
const getStoredPageSize = React.useCallback((): number => {
if (!tableKey) {
return initialPageSize;
}
const sortingKey = useMemo(() => getSortingStorageKey(), []);
const columnKey = useMemo(() => getColumnStorageKey(), []);
const pageKey = useMemo(() => getPageStorageKey(), []);
try {
const stored = localStorage.getItem(`table-page-size-${tableKey}`);
const [sorting, setSorting] = useState<SortingState>(() => loadSorting(sortingKey) ?? initialSorting);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
const [expanded, setExpanded] = useState<ExpandedState>({});
if (stored) {
const parsed = Number.parseInt(stored, 10);
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() =>
isColumnVisibilityControlled ? {} : (loadColumnVisibility(columnKey) ?? {}),
);
return Number.isNaN(parsed) ? initialPageSize : parsed;
}
} catch {
// Ignore localStorage errors
}
const [pagination, setPagination] = useState(() => {
const stored = loadPageState(pageKey);
return initialPageSize;
}, [tableKey, initialPageSize]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [expanded, setExpanded] = React.useState<ExpandedState>({});
const [pagination, setPagination] = React.useState({
pageIndex: pageIndex ?? 0,
pageSize: getStoredPageSize(),
return {
pageIndex: isPageControlled ? (externalPageIndex ?? 0) : (stored?.page ?? 0),
pageSize: stored?.pageSize ?? initialPageSize,
};
});
useEffectAfterMount(() => {
saveSorting(sortingKey, sorting);
}, [sorting, sortingKey]);
useEffectAfterMount(() => {
if (!isColumnVisibilityControlled) {
saveColumnVisibility(columnKey, internalColumnVisibility);
}
}, [internalColumnVisibility, columnKey, isColumnVisibilityControlled]);
useEffectAfterMount(() => {
savePageState(pageKey, {
page: isPageControlled ? 0 : pagination.pageIndex,
pageSize: pagination.pageSize,
});
}, [pagination.pageIndex, pagination.pageSize, pageKey, isPageControlled]);
const columnVisibility = externalColumnVisibility ?? internalColumnVisibility;
const handleColumnVisibilityChange = React.useCallback(
const handleColumnVisibilityChange = useCallback(
(updaterOrValue: ((old: VisibilityState) => VisibilityState) | VisibilityState) => {
if (onColumnVisibilityChange) {
const newValue =
@@ -116,33 +132,43 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
[onColumnVisibilityChange, externalColumnVisibility],
);
// Sync external pageIndex with internal state
React.useEffect(() => {
if (pageIndex !== undefined && pageIndex !== pagination.pageIndex) {
setPagination((prev) => ({ ...prev, pageIndex }));
const previousExternalPageIndexReference = useRef(externalPageIndex);
useEffectAfterMount(() => {
if (externalPageIndex !== undefined && externalPageIndex !== previousExternalPageIndexReference.current) {
previousExternalPageIndexReference.current = externalPageIndex;
setPagination((previous) => ({ ...previous, pageIndex: externalPageIndex }));
}
}, [pageIndex, pagination.pageIndex]);
}, [externalPageIndex]);
// Save page size to localStorage when it changes
const handlePageSizeChange = React.useCallback(
(newPageSize: number) => {
setPagination(() => ({ pageIndex: 0, pageSize: newPageSize }));
const handlePageSizeChange = useCallback((newPageSize: number) => {
setPagination({ pageIndex: 0, pageSize: newPageSize });
}, []);
if (tableKey) {
try {
localStorage.setItem(`table-page-size-${tableKey}`, String(newPageSize));
} catch {
// Ignore localStorage errors
}
const paginationReference = useRef(pagination);
paginationReference.current = pagination;
const handlePaginationChange = useCallback(
(
updater:
| ((old: { pageIndex: number; pageSize: number }) => { pageIndex: number; pageSize: number })
| { pageIndex: number; pageSize: number },
) => {
const current = paginationReference.current;
const newPagination = typeof updater === 'function' ? updater(current) : updater;
setPagination(newPagination);
if (onPageChange && newPagination.pageIndex !== current.pageIndex) {
onPageChange(newPagination.pageIndex);
}
},
[tableKey],
[onPageChange],
);
const table = useReactTable({
autoResetPageIndex: false,
columns,
data,
enableSortingRemoval: true,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -151,14 +177,7 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: handleColumnVisibilityChange,
onExpandedChange: setExpanded,
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function' ? updater(pagination) : updater;
setPagination(newPagination);
if (onPageChange && newPagination.pageIndex !== pagination.pageIndex) {
onPageChange(newPagination.pageIndex);
}
},
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
@@ -171,6 +190,23 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
},
});
const handleRowClick = useCallback(
(row: Row<TData>) => {
if (onRowClick) {
onRowClick(row.original);
} else {
row.toggleExpanded();
}
},
[onRowClick],
);
const pageSizeValue = pagination.pageSize >= data.length && data.length > 0 ? 'all' : String(pagination.pageSize);
const totalRows = table.getFilteredRowModel().rows.length;
const rangeStart = totalRows > 0 ? pagination.pageIndex * pagination.pageSize + 1 : 0;
const rangeEnd = Math.min((pagination.pageIndex + 1) * pagination.pageSize, totalRows);
return (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
@@ -195,19 +231,17 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
className="capitalize"
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e) => e.preventDefault()}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
.map((column) => (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
className="capitalize"
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(event) => event.preventDefault()}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -216,53 +250,46 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
className={header.column.columnDef.meta?.headerClassName}
key={header.id}
style={
header.column.columnDef.size
? {
maxWidth: header.column.columnDef.size,
minWidth: header.column.columnDef.size,
width: header.column.columnDef.size,
}
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
{headerGroup.headers.map((header) => (
<TableHead
className={header.column.columnDef.meta?.headerClassName}
key={header.id}
style={
header.column.columnDef.size
? {
maxWidth: header.column.columnDef.size,
minWidth: header.column.columnDef.size,
width: header.column.columnDef.size,
}
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => {
const contextMenuContent = renderRowContextMenu?.(row.original);
const tableRow = (
<TableRow
className="group hover:bg-muted/50 cursor-pointer"
className={cn('group hover:bg-muted/50', isRowInteractive && 'cursor-pointer')}
data-state={row.getIsSelected() && 'selected'}
onClick={() => {
if (onRowClick) {
onRowClick(row.original);
} else {
row?.toggleExpanded();
}
}}
onClick={() => handleRowClick(row)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={cell.column.columnDef.meta?.cellClassName}
key={cell.id}
onClick={(e) => {
// Prevent row click handler when clicking on action buttons
if (cell.column.id === 'actions') {
e.stopPropagation();
onClick={(event) => {
if (cell.column.columnDef.meta?.preventRowClick) {
event.stopPropagation();
}
}}
style={
@@ -279,18 +306,31 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && renderSubComponent && (
<TableRow className="cursor-default border-0 hover:bg-transparent">
<TableCell
className="p-0"
colSpan={row.getVisibleCells().length}
>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</React.Fragment>
))
);
return (
<Fragment key={row.id}>
{contextMenuContent ? (
<ContextMenu>
<ContextMenuTrigger asChild>{tableRow}</ContextMenuTrigger>
<ContextMenuContent>{contextMenuContent}</ContextMenuContent>
</ContextMenu>
) : (
tableRow
)}
{row.getIsExpanded() && renderSubComponent && (
<TableRow className="cursor-default border-0 hover:bg-transparent">
<TableCell
className="p-0"
colSpan={row.getVisibleCells().length}
>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
);
})
) : (
<TableRow>
<TableCell
@@ -304,71 +344,84 @@ function DataTableInner<TData, TValue>(props: DataTableProps<TData, TValue>) {
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between gap-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{!!table.getFilteredSelectedRowModel().rows.length && (
<div className="flex flex-wrap items-center justify-between gap-4 px-4 py-4">
<div className="text-muted-foreground flex-1 text-xs text-nowrap">
{totalRows > 0 ? (
<>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
Showing {rangeStart}{rangeEnd} of {totalRows}
</>
) : (
'No results'
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">Rows per page:</span>
<Select
onValueChange={(value) => {
const pageSize = value === 'all' ? data.length : Number.parseInt(value, 10);
handlePageSizeChange(pageSize);
}}
value={
pagination.pageSize >= data.length && data.length > 0
? 'all'
: String(pagination.pageSize)
}
<span className="text-xs font-medium">Rows per page</span>
<Select
onValueChange={(value) => {
const pageSize = value === 'all' ? data.length : Number.parseInt(value, 10);
handlePageSizeChange(pageSize);
}}
value={pageSizeValue}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent
className="min-w-16"
side="top"
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem
key={size}
value={String(size)}
>
{size}
</SelectItem>
))}
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
{(table.getCanPreviousPage() || table.getCanNextPage()) && (
<div className="flex gap-2">
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="sm"
variant="outline"
>
Previous
</Button>
<Button
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="sm"
variant="outline"
>
Next
</Button>
</div>
)}
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem
key={size}
value={String(size)}
>
{size}
</SelectItem>
))}
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center text-xs font-medium lg:w-20">
Page {pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex items-center gap-1">
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.firstPage()}
size="icon-xs"
variant="outline"
>
<ChevronsLeft />
</Button>
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon-xs"
variant="outline"
>
<ChevronLeft />
</Button>
<Button
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon-xs"
variant="outline"
>
<ChevronRight />
</Button>
<Button
disabled={!table.getCanNextPage()}
onClick={() => table.lastPage()}
size="icon-xs"
variant="outline"
>
<ChevronsRight />
</Button>
</div>
</div>
</div>
);
}
const DataTable = DataTableInner as <TData, TValue = never>(props: DataTableProps<TData, TValue>) => React.ReactElement;
export { DataTable };
+2 -2
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-xs',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-background/80 fixed inset-0 z-50 backdrop-blur-xs',
className,
)}
ref={ref}
@@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg outline-0 duration-200 sm:max-w-lg',
className,
)}
ref={ref}
+104
View File
@@ -0,0 +1,104 @@
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
className={cn('fixed inset-0 z-50 bg-black/80', className)}
ref={ref}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ children, className, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
className={cn(
'bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border',
className,
)}
ref={ref}
{...props}
>
<div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
ref={ref}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
ref={ref}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
};
+7 -7
View File
@@ -24,7 +24,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
>(({ children, className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
@@ -43,7 +43,7 @@ const DropdownMenuSubContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
ref={ref}
@@ -59,7 +59,7 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
@@ -96,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
ref={ref}
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
>(({ children, className, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
ref={ref}
@@ -153,7 +153,7 @@ const DropdownMenuSeparator = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
ref={ref}
{...props}
/>
+3 -3
View File
@@ -6,7 +6,7 @@ function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-4 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
'flex min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
className,
)}
data-slot="empty"
@@ -43,7 +43,7 @@ const emptyMediaVariants = cva(
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex w-full min-w-0 max-w-sm flex-col items-center gap-2 text-balance text-sm', className)}
className={cn('flex w-full max-w-sm min-w-0 flex-col items-center gap-2 text-sm text-balance', className)}
data-slot="empty-content"
{...props}
/>
@@ -54,7 +54,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
className={cn(
'text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
data-slot="empty-description"
+3 -3
View File
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'group/input-group shadow-2xs relative flex w-full items-center rounded-md border border-input outline-hidden transition-[color,box-shadow] dark:bg-input/30',
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-2xs outline-hidden transition-[color,box-shadow]',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
@@ -21,7 +21,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col [&>input]:has-[>[data-align=block-end]]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring',
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
// Error state.
'has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
@@ -113,7 +113,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
return (
<input
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'border-input dark:bg-input/30 file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
// Hide spinner arrows for number inputs
type === 'number' &&
'[appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none',
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+1 -1
View File
@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
<PopoverPrimitive.Content
align={align}
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
ref={ref}
+2 -2
View File
@@ -21,13 +21,13 @@ const ResizableHandle = ({
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border">
<DragHandleDots2Icon className="h-2.5 w-2.5" />
</div>
)}
+2 -2
View File
@@ -27,7 +27,7 @@ const ScrollBar = React.forwardRef<
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'flex touch-none transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
className,
@@ -36,7 +36,7 @@ const ScrollBar = React.forwardRef<
ref={ref}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+4 -4
View File
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
>(({ children, className, ...props }, ref) => (
<SelectPrimitive.Trigger
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&>span]:line-clamp-1',
'border-input ring-offset-background focus:ring-ring data-placeholder:text-muted-foreground flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
ref={ref}
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
>(({ children, className, ...props }, ref) => (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
ref={ref}
@@ -129,7 +129,7 @@ const SelectSeparator = React.forwardRef<
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
ref={ref}
{...props}
/>
+1 -5
View File
@@ -8,11 +8,7 @@ const Separator = React.forwardRef<
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, decorative = true, orientation = 'horizontal', ...props }, ref) => (
<SeparatorPrimitive.Root
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
className={cn('bg-border shrink-0', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className)}
decorative={decorative}
orientation={orientation}
ref={ref}
+7 -4
View File
@@ -49,12 +49,15 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
container?: HTMLElement | null;
overlay?: boolean;
}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ children, className, side = 'right', ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
({ children, className, container, overlay = true, side = 'right', ...props }, ref) => (
<SheetPortal container={container ?? undefined}>
{overlay && <SheetOverlay />}
<SheetPrimitive.Content
className={cn(sheetVariants({ side }), className)}
ref={ref}
+1 -1
View File
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-primary/10', className)}
className={cn('bg-primary/10 animate-pulse rounded-md', className)}
{...props}
/>
);
+2 -2
View File
@@ -16,8 +16,8 @@ export function StatusCard({ action, className, description, icon, title }: Stat
<Card className={cn('', className)}>
<CardContent className="flex flex-col items-center justify-center px-4 py-8 text-center">
{icon && <div className="mb-4 flex items-center justify-center">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && <div className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</div>}
<h3 className="text-foreground text-lg font-semibold">{title}</h3>
{description && <div className="text-muted-foreground mt-2 max-w-sm text-sm">{description}</div>}
{action && <div className="mt-4">{action}</div>}
</CardContent>
</Card>
+2 -2
View File
@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@@ -17,7 +17,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
+4 -4
View File
@@ -40,7 +40,7 @@ TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
className={cn('border-t bg-muted/50 font-medium last:[&>tr]:border-b-0', className)}
className={cn('bg-muted/50 border-t font-medium last:[&>tr]:border-b-0', className)}
ref={ref}
{...props}
/>
@@ -51,7 +51,7 @@ TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
className={cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', className)}
ref={ref}
{...props}
/>
@@ -63,7 +63,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
({ className, ...props }, ref) => (
<th
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
className,
)}
ref={ref}
@@ -87,7 +87,7 @@ TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption
className={cn('mt-4 text-sm text-muted-foreground', className)}
className={cn('text-muted-foreground mt-4 text-sm', className)}
ref={ref}
{...props}
/>
+3 -3
View File
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
'bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1',
className,
)}
ref={ref}
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
className,
)}
ref={ref}
@@ -41,7 +41,7 @@ const TabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
className={cn(
'mt-4 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
className,
)}
ref={ref}
@@ -7,7 +7,7 @@ function TextareaAutosize({ className, ...props }: React.ComponentProps<typeof R
return (
<ReactTextareaAutosize
className={cn(
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-2xs flex min-h-16 w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-base outline-hidden transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:text-sm',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 flex min-h-16 w-full resize-none rounded-md border bg-transparent px-3 py-2 text-base shadow-2xs outline-hidden transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
data-slot="textarea-autosize"
+1 -1
View File
@@ -82,7 +82,7 @@ const Textarea = React.forwardRef<TextareaRef, TextareaProps>(
return (
<textarea
className={cn(
'flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex w-full resize-none rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={textareaRef}
+1 -1
View File
@@ -5,7 +5,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'group/toggle inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
defaultVariants: {
size: 'default',
+1 -1
View File
@@ -16,7 +16,7 @@ const TooltipContent = React.forwardRef<
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-xs shadow-md',
className,
)}
ref={ref}
@@ -0,0 +1,251 @@
import { Activity, CircleDollarSign, Cpu, GitFork, Loader2 } from 'lucide-react';
import { useMemo } from 'react';
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowStatsByFlowQuery,
useToolcallsStatsByFlowQuery,
useToolcallsStatsByFunctionForFlowQuery,
useUsageStatsByAgentTypeForFlowQuery,
useUsageStatsByFlowQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
<TableCell className="font-medium">{label}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageIn)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageOut)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageCacheIn)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageCacheOut)}</TableCell>
<TableCell className="text-right">{formatCost(stats.totalUsageCostIn)}</TableCell>
<TableCell className="text-right">{formatCost(stats.totalUsageCostOut)}</TableCell>
<TableCell className="text-right font-semibold">
{formatCost(stats.totalUsageCostIn + stats.totalUsageCostOut)}
</TableCell>
</TableRow>
);
const LoadingTable = () => (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => {
const { data: usageData, loading: usageLoading } = useUsageStatsByFlowQuery({
variables: { flowId },
});
const { data: usageByAgentData, loading: usageByAgentLoading } = useUsageStatsByAgentTypeForFlowQuery({
variables: { flowId },
});
const { data: toolcallsData, loading: toolcallsLoading } = useToolcallsStatsByFlowQuery({
variables: { flowId },
});
const { data: toolcallsByFunctionData, loading: toolcallsByFunctionLoading } =
useToolcallsStatsByFunctionForFlowQuery({
variables: { flowId },
});
const { data: flowStatsData, loading: flowStatsLoading } = useFlowStatsByFlowQuery({
variables: { flowId },
});
const usage = usageData?.usageStatsByFlow;
const toolcalls = toolcallsData?.toolcallsStatsByFlow;
const flowStats = flowStatsData?.flowStatsByFlow;
const totalCost = usage ? usage.totalUsageCostIn + usage.totalUsageCostOut : 0;
const totalTokens = usage ? usage.totalUsageIn + usage.totalUsageOut : 0;
const agentTypeRows = useMemo(() => {
const seen = new Set<string>();
return (usageByAgentData?.usageStatsByAgentTypeForFlow ?? [])
.filter((item) => {
if (seen.has(item.agentType)) {
return false;
}
seen.add(item.agentType);
return true;
})
.map((item) => ({
label: item.agentType,
stats: item.stats,
}));
}, [usageByAgentData]);
const toolcallsByFunction = useMemo(() => {
const seen = new Set<string>();
return [...(toolcallsByFunctionData?.toolcallsStatsByFunctionForFlow ?? [])]
.filter((item) => {
if (seen.has(item.functionName)) {
return false;
}
seen.add(item.functionName);
return true;
})
.sort((a, b) => b.totalCount - a.totalCount);
}, [toolcallsByFunctionData]);
const anyLoading = usageLoading || toolcallsLoading || flowStatsLoading;
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
description="LLM spending for this flow"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Cost"
value={formatCost(totalCost)}
/>
<StatCard
description="Input + Output tokens"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
description={`Duration: ${toolcalls ? formatDuration(toolcalls.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tool Calls"
value={toolcalls ? formatNumber(toolcalls.totalCount) : '0'}
/>
<StatCard
description={`Subtasks: ${flowStats?.totalSubtasksCount ?? 0} · Assistants: ${flowStats?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={anyLoading}
title="Tasks"
value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'}
/>
</div>
{!!agentTypeRows.length && (
<Card>
<CardHeader>
<CardTitle>Usage by Agent Type</CardTitle>
<CardDescription>LLM token usage and costs per agent type in this flow</CardDescription>
</CardHeader>
<CardContent>
{usageByAgentLoading ? (
<LoadingTable />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent Type</TableHead>
<TableHead className="text-right">Tokens In</TableHead>
<TableHead className="text-right">Tokens Out</TableHead>
<TableHead className="text-right">Cache In</TableHead>
<TableHead className="text-right">Cache Out</TableHead>
<TableHead className="text-right">Cost In</TableHead>
<TableHead className="text-right">Cost Out</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agentTypeRows.map((row) => (
<UsageStatsRow
key={row.label}
label={row.label}
stats={row.stats}
/>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{!!toolcallsByFunction.length && (
<Card>
<CardHeader>
<CardTitle>Tool Calls by Function</CardTitle>
<CardDescription>Execution statistics per tool function in this flow</CardDescription>
</CardHeader>
<CardContent>
{toolcallsByFunctionLoading ? (
<LoadingTable />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Function</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Total Duration</TableHead>
<TableHead className="text-right">Avg Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{toolcallsByFunction.map((item) => (
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</TableCell>
<TableCell className="text-right">
{formatNumber(item.totalCount)}
</TableCell>
<TableCell className="text-right">
{formatDuration(item.totalDurationSeconds)}
</TableCell>
<TableCell className="text-right">
{formatDuration(item.avgDurationSeconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
};
@@ -0,0 +1,29 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FlowDashboardOverview } from '@/features/flows/dashboard/flow-dashboard-overview';
import { useFlow } from '@/providers/flow-provider';
const FlowDashboard = () => {
const { flowId } = useFlow();
if (!flowId) {
return (
<div className="text-muted-foreground flex items-center justify-center py-12">
Select a flow to view the dashboard
</div>
);
}
return (
<Tabs defaultValue="overview">
<TabsList className="hidden">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<FlowDashboardOverview flowId={flowId} />
</TabsContent>
</Tabs>
);
};
export default FlowDashboard;
@@ -1,73 +1,48 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import FlowDashboard from '@/features/flows/dashboard/flow-dashboard';
import FlowAssistantMessages from '@/features/flows/messages/flow-assistant-messages';
import FlowAutomationMessages from '@/features/flows/messages/flow-automation-messages';
import { useFlow } from '@/providers/flow-provider';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
const FlowCentralTabs = () => {
const { flowData, isLoading } = useFlow();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<null | string>(null);
// Determine default tab based on priority: manual selection > URL parameter > auto-detection
const defaultTab = useMemo(() => {
// If user manually selected a tab, use it
if (activeTab) {
return activeTab;
}
// Check URL parameter
const tabParam = searchParams.get('tab');
if (tabParam === 'automation' || tabParam === 'assistant') {
return tabParam;
}
// Auto-detect: switch to assistant tab if flow is loaded and messageLogs are empty
if (!isLoading && !flowData?.messageLogs?.length) {
return 'assistant';
}
return 'automation';
}, [activeTab, searchParams, isLoading, flowData?.messageLogs]);
// Handle tab change - update both state and URL parameter
const handleTabChange = (tab: string) => {
setActiveTab(tab);
setSearchParams({ tab });
};
const { handleTabChange, resolvedTab } = useFlowTabDetection();
return (
<Tabs
className="flex size-full flex-col"
onValueChange={handleTabChange}
value={defaultTab}
value={resolvedTab}
>
<div className="max-w-full">
<ScrollArea className="w-full pb-2">
<ScrollArea className="w-full pb-3">
<TabsList className="flex w-fit">
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="assistant">Assistant</TabsTrigger>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="automation"
>
<FlowAutomationMessages />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="assistant"
>
<FlowAssistantMessages />
</TabsContent>
<TabsContent
className="mt-1 flex-1 overflow-auto pr-4"
value="dashboard"
>
<FlowDashboard />
</TabsContent>
</Tabs>
);
};
+132 -3
View File
@@ -1,10 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowUpIcon, Check, ChevronDown, Square, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowUp, Check, ChevronDown, FileSymlink, FileText, Square, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -26,6 +28,7 @@ import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getProviderDisplayName } from '@/models/provider';
import { useProviders } from '@/providers/providers-provider';
import { type Template, useTemplates } from '@/providers/templates-provider';
const formSchema = z.object({
message: z.string().trim().min(1, { message: 'Message cannot be empty' }),
@@ -61,7 +64,24 @@ export const FlowForm = ({
type,
}: FlowFormProps) => {
const { providers, setSelectedProvider } = useProviders();
const { templates } = useTemplates();
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [pendingTemplate, setPendingTemplate] = useState<null | Template>(null);
const [providerSearch, setProviderSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const filteredTemplates = useMemo(() => {
if (!templateSearch.trim()) {
return templates;
}
const searchLower = templateSearch.toLowerCase();
return templates.filter(
(template) =>
template.title.toLowerCase().includes(searchLower) || template.text.toLowerCase().includes(searchLower),
);
}, [templates, templateSearch]);
const filteredProviders = useMemo(() => {
if (!providerSearch.trim()) {
@@ -151,6 +171,29 @@ export const FlowForm = ({
handleFormSubmit(handleSubmit)();
};
const handleApplyTemplate = useCallback(
(template: Template) => {
const currentMessage = getValues('message')?.trim() ?? '';
if (currentMessage.length > 0) {
setPendingTemplate(template);
setIsReplaceConfirmOpen(true);
} else {
setValue('message', template.text, { shouldValidate: true });
setTemplateSearch('');
}
},
[getValues, setValue],
);
const handleConfirmReplaceTemplate = useCallback(() => {
if (pendingTemplate) {
setValue('message', pendingTemplate.text, { shouldValidate: true });
setTemplateSearch('');
setPendingTemplate(null);
}
}, [pendingTemplate, setValue]);
return (
<Form {...form}>
<form onSubmit={handleFormSubmit(handleSubmit)}>
@@ -277,6 +320,7 @@ export const FlowForm = ({
);
}}
/>
{type === 'assistant' && (
<FormField
control={control}
@@ -313,6 +357,75 @@ export const FlowForm = ({
)}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton
disabled={isFormDisabled}
variant="ghost"
>
<FileText className="shrink-0" />
<ChevronDown />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setTemplateSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={templateSearch}
/>
{templateSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setTemplateSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredTemplates.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{templateSearch ? 'No results found' : 'No available templates'}
</DropdownMenuItem>
) : (
filteredTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => {
if (isFormDisabled) {
return;
}
handleApplyTemplate(template);
}}
>
<span className="max-w-80 flex-1 truncate">
{template.title}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{!isLoading || isSubmitting ? (
<InputGroupButton
className="ml-auto"
@@ -321,7 +434,7 @@ export const FlowForm = ({
type="submit"
variant="default"
>
{isSubmitting ? <Spinner variant="circle" /> : <ArrowUpIcon />}
{isSubmitting ? <Spinner variant="circle" /> : <ArrowUp />}
</InputGroupButton>
) : (
<InputGroupButton
@@ -341,6 +454,22 @@ export const FlowForm = ({
)}
/>
</form>
<ConfirmationDialog
confirmIcon={<FileSymlink />}
confirmText="Replace"
confirmVariant="default"
description="Current message has content. Replace with the selected template?"
handleConfirm={handleConfirmReplaceTemplate}
handleOpenChange={(open) => {
if (!open) {
setPendingTemplate(null);
}
setIsReplaceConfirmOpen(open);
}}
isOpen={isReplaceConfirmOpen}
title="Replace content?"
/>
</Form>
);
};
+20 -13
View File
@@ -1,10 +1,9 @@
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useRef } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import FlowAgents from '@/features/flows/agents/flow-agents';
import FlowDashboard from '@/features/flows/dashboard/flow-dashboard';
import FlowAssistantMessages from '@/features/flows/messages/flow-assistant-messages';
import FlowAutomationMessages from '@/features/flows/messages/flow-automation-messages';
import FlowScreenshots from '@/features/flows/screenshots/flow-screenshots';
@@ -16,7 +15,7 @@ import { useBreakpoint } from '@/hooks/use-breakpoint';
interface FlowTabsProps {
activeTab: string;
onTabChange: Dispatch<SetStateAction<string>>;
onTabChange: (tab: string) => void;
}
const FlowTabs = ({ activeTab, onTabChange }: FlowTabsProps) => {
@@ -40,10 +39,11 @@ const FlowTabs = ({ activeTab, onTabChange }: FlowTabsProps) => {
value={activeTab}
>
<div className="max-w-full pr-4">
<ScrollArea className="w-full pb-2">
<ScrollArea className="w-full pb-3">
<TabsList className="flex w-fit">
{!isDesktop && <TabsTrigger value="automation">Automation</TabsTrigger>}
{!isDesktop && <TabsTrigger value="assistant">Assistant</TabsTrigger>}
{!isDesktop && <TabsTrigger value="dashboard">Dashboard</TabsTrigger>}
<TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
<TabsTrigger value="agents">Agents</TabsTrigger>
@@ -58,7 +58,7 @@ const FlowTabs = ({ activeTab, onTabChange }: FlowTabsProps) => {
{/* Mobile Tabs only */}
{!isDesktop && (
<TabsContent
className="mt-2 flex-1 overflow-auto"
className="mt-1 flex-1 overflow-auto"
value="automation"
>
<FlowAutomationMessages className="pr-4" />
@@ -66,51 +66,59 @@ const FlowTabs = ({ activeTab, onTabChange }: FlowTabsProps) => {
)}
{!isDesktop && (
<TabsContent
className="mt-2 flex-1 overflow-auto"
className="mt-1 flex-1 overflow-auto"
value="assistant"
>
<FlowAssistantMessages className="pr-4" />
</TabsContent>
)}
{!isDesktop && (
<TabsContent
className="mt-1 flex-1 overflow-auto pr-4"
value="dashboard"
>
<FlowDashboard />
</TabsContent>
)}
{/* Desktop and Mobile Tabs */}
<TabsContent
className="mt-2 flex-1 overflow-auto"
className="mt-1 flex-1 overflow-auto"
value="terminal"
>
<FlowTerminal />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="tasks"
>
<FlowTasks />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="agents"
>
<FlowAgents />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="tools"
>
<FlowTools />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="vectorStores"
>
<FlowVectorStores />
</TabsContent>
<TabsContent
className="mt-2 flex-1 overflow-auto pr-4"
className="mt-1 flex-1 overflow-auto pr-4"
value="screenshots"
>
<FlowScreenshots />
@@ -120,4 +128,3 @@ const FlowTabs = ({ activeTab, onTabChange }: FlowTabsProps) => {
};
export default FlowTabs;
@@ -183,7 +183,7 @@ const FlowTasksDropdown = ({ disabled, onChange, value }: FlowTasksDropdownProps
toggleSubtaskSelection(subtask.id);
}}
>
<div className="flex-1 truncate text-sm text-muted-foreground">
<div className="text-muted-foreground flex-1 truncate text-sm">
{subtask.title}
</div>
<Check
@@ -198,7 +198,7 @@ const FlowTasksDropdown = ({ disabled, onChange, value }: FlowTasksDropdownProps
))
) : (
<CommandItem
className="justify-center py-6 text-center text-muted-foreground"
className="text-muted-foreground justify-center py-6 text-center"
disabled
>
No available tasks
@@ -41,4 +41,3 @@ const FlowTaskStatusIcon = ({ className, status, tooltip }: FlowTaskStatusIconPr
};
export default FlowTaskStatusIcon;
@@ -1,17 +1,27 @@
import { zodResolver } from '@hookform/resolvers/zod';
import '@xterm/xterm/css/xterm.css';
import debounce from 'lodash/debounce';
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
import { ChevronDown, ChevronUp, ListFilter, Search, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import Terminal from '@/components/shared/terminal';
import { Button } from '@/components/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { Form, FormControl, FormField } from '@/components/ui/form';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { useFlow } from '@/providers/flow-provider';
import FlowTasksDropdown from '../flow-tasks-dropdown';
const searchFormSchema = z.object({
filter: z
.object({
subtaskIds: z.array(z.string()),
taskIds: z.array(z.string()),
})
.optional(),
search: z.string(),
});
@@ -25,12 +35,17 @@ const FlowTerminal = () => {
const form = useForm<z.infer<typeof searchFormSchema>>({
defaultValues: {
filter: {
subtaskIds: [],
taskIds: [],
},
search: '',
},
resolver: zodResolver(searchFormSchema),
});
const searchValue = form.watch('search');
const filter = form.watch('filter');
// Create debounced function to update search value
const debouncedUpdateSearch = useMemo(
@@ -59,22 +74,54 @@ const FlowTerminal = () => {
// Clear search when flow changes to prevent stale search state
useEffect(() => {
form.reset({ search: '' });
form.reset({
filter: {
subtaskIds: [],
taskIds: [],
},
search: '',
});
setDebouncedSearchValue('');
debouncedUpdateSearch.cancel();
}, [flowId, form, debouncedUpdateSearch]);
// Filter logs based on debounced search value for better performance
const hasActiveFilters = useMemo(() => {
const hasSearch = !!searchValue.trim();
const hasTaskFilters = !!(filter?.taskIds?.length || filter?.subtaskIds?.length);
return hasSearch || hasTaskFilters;
}, [searchValue, filter]);
const filteredLogs = useMemo(() => {
const search = debouncedSearchValue.toLowerCase().trim();
const logs = terminalLogs.map((log) => log.text);
if (!search) {
return logs;
let filtered = terminalLogs;
if (filter?.taskIds?.length || filter?.subtaskIds?.length) {
const selectedTaskIds = new Set(filter.taskIds ?? []);
const selectedSubtaskIds = new Set(filter.subtaskIds ?? []);
filtered = filtered.filter((log) => {
if (log.taskId && selectedTaskIds.has(log.taskId)) {
return true;
}
if (log.subtaskId && selectedSubtaskIds.has(log.subtaskId)) {
return true;
}
return false;
});
}
return logs.filter((log) => log.toLowerCase().includes(search));
}, [terminalLogs, debouncedSearchValue]);
const texts = filtered.map((log) => log.text);
if (!search) {
return texts;
}
return texts.filter((text) => text.toLowerCase().includes(search));
}, [terminalLogs, debouncedSearchValue, filter]);
const handleFindNext = () => {
if (terminalRef.current && debouncedSearchValue.trim()) {
@@ -94,19 +141,32 @@ const FlowTerminal = () => {
debouncedUpdateSearch.cancel();
};
const handleResetFilters = () => {
form.reset({
filter: {
subtaskIds: [],
taskIds: [],
},
search: '',
});
setDebouncedSearchValue('');
debouncedUpdateSearch.cancel();
};
const hasSearchValue = !!debouncedSearchValue.trim();
const hasLogs = filteredLogs.length > 0;
return (
<div className="flex size-full flex-col gap-4">
<div className="sticky top-0 z-10 bg-background pr-4">
<div className="bg-background sticky top-0 z-10 pr-4">
<Form {...form}>
<div className="p-px">
<div className="flex gap-2 p-px">
<FormField
control={form.control}
name="search"
render={({ field }) => (
<FormControl>
<InputGroup>
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
@@ -152,15 +212,55 @@ const FlowTerminal = () => {
</FormControl>
)}
/>
<FormField
control={form.control}
name="filter"
render={({ field }) => (
<FormControl>
<FlowTasksDropdown
onChange={field.onChange}
value={field.value}
/>
</FormControl>
)}
/>
</div>
</Form>
</div>
<Terminal
className="w-full grow"
logs={filteredLogs}
ref={terminalRef}
searchValue={debouncedSearchValue}
/>
{hasLogs ? (
<Terminal
className="w-full grow"
logs={filteredLogs}
ref={terminalRef}
searchValue={debouncedSearchValue}
/>
) : hasActiveFilters ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListFilter />
</EmptyMedia>
<EmptyTitle>No terminal logs found</EmptyTitle>
<EmptyDescription>Try adjusting your search or filter parameters</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
onClick={handleResetFilters}
variant="outline"
>
<X />
Reset filters
</Button>
</EmptyContent>
</Empty>
) : (
<Terminal
className="w-full grow"
logs={filteredLogs}
ref={terminalRef}
searchValue={debouncedSearchValue}
/>
)}
</div>
);
};
@@ -36,4 +36,3 @@ const FlowVectorStoreActionIcon = ({ action, className, tooltip = action }: Flow
};
export default FlowVectorStoreActionIcon;
@@ -1,6 +1,8 @@
import type { VisibilityState } from '@tanstack/react-table';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getColumnStorageKey } from '@/lib/storage-keys';
export interface ColumnPriority {
alwaysVisible?: boolean;
@@ -23,6 +25,16 @@ const DEFAULT_BREAKPOINTS = [
{ hiddenPriorities: [1, 2, 3, 4, 5], width: 0 },
];
function loadUserPreferences(key: string): Record<string, boolean> {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
export const useAdaptiveColumnVisibility = ({
breakpoints = DEFAULT_BREAKPOINTS,
columns,
@@ -30,33 +42,26 @@ export const useAdaptiveColumnVisibility = ({
}: UseAdaptiveColumnVisibilityOptions) => {
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1400);
const localStorageKey = `table-column-visibility-${tableKey}`;
const localStorageKey = useMemo(() => getColumnStorageKey(tableKey), [tableKey]);
const getUserPreferences = (): Record<string, boolean> => {
try {
const stored = localStorage.getItem(localStorageKey);
const [userPreferences, setUserPreferences] = useState<Record<string, boolean>>(() =>
loadUserPreferences(localStorageKey),
);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
};
const [userPreferences, setUserPreferences] = useState<Record<string, boolean>>(getUserPreferences);
const saveUserPreferences = (preferences: Record<string, boolean>) => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(preferences));
setUserPreferences(preferences);
} catch (error) {
console.error('Failed to save column visibility preferences:', error);
}
};
const saveUserPreferences = useCallback(
(preferences: Record<string, boolean>) => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(preferences));
setUserPreferences(preferences);
} catch {
/* localStorage may be unavailable */
}
},
[localStorageKey],
);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
@@ -64,38 +69,37 @@ export const useAdaptiveColumnVisibility = ({
}, []);
const columnVisibility = useMemo((): VisibilityState => {
const activeBreakpoint = breakpoints.find((bp) => windowWidth >= bp.width) ||
breakpoints[breakpoints.length - 1] || { hiddenPriorities: [], width: 0 };
const activeBreakpoint = breakpoints.find((breakpoint) => windowWidth >= breakpoint.width) ??
breakpoints.at(-1) ?? { hiddenPriorities: [], width: 0 };
const visibility: VisibilityState = {};
return Object.fromEntries(
columns.map((column) => {
if (column.alwaysVisible) {
return [column.id, true];
}
columns.forEach((column) => {
if (column.alwaysVisible) {
visibility[column.id] = true;
const shouldHideByWidth = activeBreakpoint.hiddenPriorities.includes(column.priority);
const userPreference = userPreferences[column.id];
return;
}
const isVisible =
userPreference !== undefined
? !shouldHideByWidth && userPreference
: !shouldHideByWidth;
const shouldHideByWidth = activeBreakpoint.hiddenPriorities.includes(column.priority);
const userPreference = userPreferences[column.id];
if (userPreference !== undefined) {
visibility[column.id] = shouldHideByWidth ? false : userPreference;
} else {
visibility[column.id] = !shouldHideByWidth;
}
});
return visibility;
return [column.id, isVisible];
}),
);
}, [windowWidth, userPreferences, columns, breakpoints]);
const updateColumnVisibility = (columnId: string, visible: boolean) => {
const newPreferences = {
...userPreferences,
[columnId]: visible,
};
saveUserPreferences(newPreferences);
};
const updateColumnVisibility = useCallback(
(columnId: string, visible: boolean) => {
saveUserPreferences({
...userPreferences,
[columnId]: visible,
});
},
[userPreferences, saveUserPreferences],
);
return {
columnVisibility,
@@ -0,0 +1,16 @@
import { type DependencyList, type EffectCallback, useEffect, useRef } from 'react';
export function useEffectAfterMount(effect: EffectCallback, dependencies: DependencyList): void {
const mountedReference = useRef(false);
useEffect(() => {
if (!mountedReference.current) {
mountedReference.current = true;
return;
}
return effect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
}
@@ -0,0 +1,50 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useFlow } from '@/providers/flow-provider';
const CENTRAL_TAB_VALUES = ['automation', 'assistant', 'dashboard'];
/**
* Detects the appropriate central tab based on priority:
* 1. Manual user selection
* 2. URL search parameter (?tab=...)
* 3. Auto-detection: 'assistant' if flow is loaded and has no message logs
* 4. Default: 'automation'
*/
export function useFlowTabDetection() {
const { flowData, isLoading } = useFlow();
const [searchParams, setSearchParams] = useSearchParams();
const [manualTab, setManualTab] = useState<null | string>(null);
const resolvedTab = useMemo(() => {
// If user manually selected a tab, use it
if (manualTab) {
return manualTab;
}
// Check URL parameter
const tabParam = searchParams.get('tab');
if (tabParam && CENTRAL_TAB_VALUES.includes(tabParam)) {
return tabParam;
}
// Auto-detect: switch to assistant tab if flow is loaded and messageLogs are empty
if (!isLoading && !flowData?.messageLogs?.length) {
return 'assistant';
}
return 'automation';
}, [manualTab, searchParams, isLoading, flowData?.messageLogs]);
const handleTabChange = useCallback(
(tab: string) => {
setManualTab(tab);
setSearchParams({ tab });
},
[setSearchParams],
);
return { handleTabChange, resolvedTab };
}
+26
View File
@@ -0,0 +1,26 @@
const STORAGE_KEY_SEPARATOR = '_4_';
export type LocalStorageKeyType = 'column' | 'page' | 'sorting';
export function getColumnStorageKey(urlPath?: string): string {
return getStorageKey('column', urlPath);
}
export function getPageStorageKey(urlPath?: string): string {
return getStorageKey('page', urlPath);
}
export function getSortingStorageKey(urlPath?: string): string {
return getStorageKey('sorting', urlPath);
}
/**
* Builds a storage key from type and current page path.
* Format: `${type}_4_${urlPath}`
* If urlPath is not passed, uses window.location.pathname (client only).
*/
export function getStorageKey(type: LocalStorageKeyType, urlPath?: string): string {
const path = urlPath ?? location?.pathname ?? '';
return `${type}${STORAGE_KEY_SEPARATOR}${path}`;
}
+51
View File
@@ -0,0 +1,51 @@
import type { SortingState, VisibilityState } from '@tanstack/react-table';
import { z } from 'zod';
const sortingSchema = z.array(z.object({ desc: z.boolean(), id: z.string() }));
const visibilitySchema = z.record(z.string(), z.boolean());
const pageStateSchema = z.object({ page: z.number(), pageSize: z.number() });
export type StoredPageState = z.infer<typeof pageStateSchema>;
function loadFromStorage<T>(key: string, schema: z.ZodType<T>): T | null {
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
const result = schema.safeParse(JSON.parse(raw));
return result.success ? result.data : null;
} catch {
return null;
}
}
function saveToStorage(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* localStorage may be unavailable */
}
}
export const loadSorting = (key: string): SortingState | null => loadFromStorage(key, sortingSchema);
export const loadColumnVisibility = (key: string): VisibilityState | null =>
loadFromStorage(key, visibilitySchema);
export const loadPageState = (key: string): StoredPageState | null =>
loadFromStorage(key, pageStateSchema);
export const saveSorting = (key: string, sorting: SortingState): void =>
saveToStorage(key, sorting);
export const saveColumnVisibility = (key: string, visibility: VisibilityState): void =>
saveToStorage(key, visibility);
export const savePageState = (key: string, state: StoredPageState): void =>
saveToStorage(key, state);
+12
View File
@@ -0,0 +1,12 @@
export function isMac(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const platform =
'userAgentData' in navigator
? ((navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ?? '')
: (navigator.platform ?? '');
return /Mac|iPhone|iPad/i.test(platform) || /Mac/i.test(navigator.userAgent);
}
@@ -0,0 +1,467 @@
import { format } from 'date-fns';
import { ChevronRight, Clock, Loader2, Wrench } from 'lucide-react';
import { useState } from 'react';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { UsageStatsPeriod } from '@/graphql/types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
useFlowsExecutionStatsByPeriodQuery,
useFlowsStatsByPeriodQuery,
useToolcallsStatsByPeriodQuery,
useUsageStatsByPeriodQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const CHART_COLORS = {
area1: 'var(--color-chart-1)',
area2: 'var(--color-chart-2)',
area3: 'var(--color-chart-3)',
bar1: 'var(--color-chart-4)',
bar2: 'var(--color-chart-5)',
};
const formatDateLabel = (dateString: string): string => {
try {
return format(new Date(dateString), 'MMM d');
} catch {
return dateString;
}
};
const ChartLoading = () => (
<div className="flex h-[300px] items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
const CustomTooltip = ({
active,
formatter,
label,
payload,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
payload?: Array<{ color: string; name: string; value: number }>;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
<p className="text-muted-foreground mb-1 text-xs">{label ? formatDateLabel(label) : ''}</p>
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
};
export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => {
const { data: usageByPeriodData, loading: usageByPeriodLoading } = useUsageStatsByPeriodQuery({
variables: { period },
});
const { data: toolcallsByPeriodData, loading: toolcallsByPeriodLoading } = useToolcallsStatsByPeriodQuery({
variables: { period },
});
const { data: flowsByPeriodData, loading: flowsByPeriodLoading } = useFlowsStatsByPeriodQuery({
variables: { period },
});
const { data: executionStatsData, loading: executionStatsLoading } = useFlowsExecutionStatsByPeriodQuery({
variables: { period },
});
const usageChartData = [...(usageByPeriodData?.usageStatsByPeriod ?? [])].reverse().map((item) => ({
cacheIn: item.stats.totalUsageCacheIn,
costIn: item.stats.totalUsageCostIn,
costOut: item.stats.totalUsageCostOut,
date: formatDateLabel(item.date),
tokensIn: item.stats.totalUsageIn,
tokensOut: item.stats.totalUsageOut,
totalCost: item.stats.totalUsageCostIn + item.stats.totalUsageCostOut,
}));
const toolcallsChartData = [...(toolcallsByPeriodData?.toolcallsStatsByPeriod ?? [])].reverse().map((item) => ({
count: item.stats.totalCount,
date: formatDateLabel(item.date),
duration: item.stats.totalDurationSeconds,
}));
const flowsChartData = [...(flowsByPeriodData?.flowsStatsByPeriod ?? [])].reverse().map((item) => ({
assistants: item.stats.totalAssistantsCount,
date: formatDateLabel(item.date),
flows: item.stats.totalFlowsCount,
subtasks: item.stats.totalSubtasksCount,
tasks: item.stats.totalTasksCount,
}));
const executionStats = executionStatsData?.flowsExecutionStatsByPeriod ?? [];
return (
<div className="flex flex-col gap-6">
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Token Usage Over Time</CardTitle>
<CardDescription>Input and output tokens processed daily</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={formatTokenCount}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip formatter={(value) => formatTokenCount(value)} />}
/>
<Area
dataKey="tokensIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Tokens In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="tokensOut"
fill={CHART_COLORS.area2}
fillOpacity={0.3}
name="Tokens Out"
stroke={CHART_COLORS.area2}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost Over Time</CardTitle>
<CardDescription>LLM spending per day</CardDescription>
</CardHeader>
<CardContent>
{usageByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<AreaChart data={usageChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickFormatter={(value) => formatCost(value)}
tickMargin={8}
/>
<Tooltip content={<CustomTooltip formatter={(value) => formatCost(value)} />} />
<Area
dataKey="costIn"
fill={CHART_COLORS.area1}
fillOpacity={0.3}
name="Cost In"
stroke={CHART_COLORS.area1}
type="monotone"
/>
<Area
dataKey="costOut"
fill={CHART_COLORS.area3}
fillOpacity={0.3}
name="Cost Out"
stroke={CHART_COLORS.area3}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tool Calls Over Time</CardTitle>
<CardDescription>Number of tool executions per day</CardDescription>
</CardHeader>
<CardContent>
{toolcallsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={toolcallsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="count"
fill={CHART_COLORS.bar1}
name="Tool Calls"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Flows Activity Over Time</CardTitle>
<CardDescription>Flows, tasks, and subtasks created per day</CardDescription>
</CardHeader>
<CardContent>
{flowsByPeriodLoading ? (
<ChartLoading />
) : (
<ResponsiveContainer
height={300}
width="100%"
>
<BarChart data={flowsChartData}>
<CartesianGrid
className="stroke-border"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<YAxis
tick={{ fill: 'var(--color-muted-foreground)', fontSize: 12 }}
tickMargin={8}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }}
/>
<Bar
dataKey="flows"
fill={CHART_COLORS.area1}
name="Flows"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="tasks"
fill={CHART_COLORS.area2}
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="subtasks"
fill={CHART_COLORS.area3}
name="Subtasks"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Flow Execution Details</CardTitle>
<CardDescription>Execution time and tool calls breakdown per flow</CardDescription>
</CardHeader>
<CardContent>
{executionStatsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : !executionStats.length ? (
<p className="text-muted-foreground py-8 text-center text-sm">
No flow executions in this period
</p>
) : (
<div className="space-y-1">
{executionStats.map((flow) => (
<FlowExecutionItem
flow={flow}
key={flow.flowId}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};
type FlowExecution = {
flowId: string;
flowTitle: string;
tasks: Array<{
subtasks: Array<{
subtaskId: string;
subtaskTitle: string;
totalDurationSeconds: number;
totalToolcallsCount: number;
}>;
taskId: string;
taskTitle: string;
totalDurationSeconds: number;
totalToolcallsCount: number;
}>;
totalAssistantsCount: number;
totalDurationSeconds: number;
totalToolcallsCount: number;
};
const FlowExecutionItem = ({ flow }: { flow: FlowExecution }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Collapsible
onOpenChange={setIsOpen}
open={isOpen}
>
<CollapsibleTrigger className="hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors">
<ChevronRight className={`size-4 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
<div className="flex-1 truncate font-medium">{flow.flowTitle || `Flow #${flow.flowId}`}</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(flow.totalDurationSeconds)}
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{flow.totalToolcallsCount}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-7 space-y-1 border-l pl-3">
{flow.tasks.map((task) => (
<TaskExecutionItem
key={task.taskId}
task={task}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
};
const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) => {
const [isOpen, setIsOpen] = useState(false);
const hasSubtasks = task.subtasks.length > 0;
return (
<Collapsible
onOpenChange={setIsOpen}
open={isOpen}
>
<CollapsibleTrigger
className="hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg px-3 py-1.5 text-left text-sm transition-colors"
disabled={!hasSubtasks}
>
{hasSubtasks ? (
<ChevronRight className={`size-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
) : (
<span className="size-3 shrink-0" />
)}
<div className="text-muted-foreground flex-1 truncate">{task.taskTitle || `Task #${task.taskId}`}</div>
<div className="text-muted-foreground flex items-center gap-4 text-xs">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(task.totalDurationSeconds)}
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{task.totalToolcallsCount}
</span>
</div>
</CollapsibleTrigger>
{hasSubtasks && (
<CollapsibleContent>
<div className="ml-6 space-y-0.5 border-l pl-3">
{task.subtasks.map((subtask) => (
<div
className="text-muted-foreground flex items-center gap-3 px-3 py-1 text-xs"
key={subtask.subtaskId}
>
<div className="flex-1 truncate">
{subtask.subtaskTitle || `Subtask #${subtask.subtaskId}`}
</div>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(subtask.totalDurationSeconds)}
</span>
<span className="flex items-center gap-1">
<Wrench className="size-3" />
{subtask.totalToolcallsCount}
</span>
</div>
</div>
))}
</div>
</CollapsibleContent>
)}
</Collapsible>
);
};
@@ -0,0 +1,237 @@
import { Activity, CircleDollarSign, Cpu, GitFork, Loader2 } from 'lucide-react';
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
useFlowsStatsTotalQuery,
useToolcallsStatsByFunctionQuery,
useToolcallsStatsTotalQuery,
useUsageStatsByAgentTypeQuery,
useUsageStatsByModelQuery,
useUsageStatsByProviderQuery,
useUsageStatsTotalQuery,
} from '@/graphql/types';
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils';
const StatCard = ({
description,
icon,
loading,
title,
value,
}: {
description: string;
icon: React.ReactNode;
loading: boolean;
title: string;
value: string;
}) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
<p className="text-muted-foreground text-xs">{description}</p>
</CardContent>
</Card>
);
const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => (
<TableRow>
<TableCell className="font-medium">{label}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageIn)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageOut)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageCacheIn)}</TableCell>
<TableCell className="text-right">{formatTokenCount(stats.totalUsageCacheOut)}</TableCell>
<TableCell className="text-right">{formatCost(stats.totalUsageCostIn)}</TableCell>
<TableCell className="text-right">{formatCost(stats.totalUsageCostOut)}</TableCell>
<TableCell className="text-right font-semibold">
{formatCost(stats.totalUsageCostIn + stats.totalUsageCostOut)}
</TableCell>
</TableRow>
);
const UsageStatsTable = ({ rows }: { rows: Array<{ label: string; stats: UsageStatsFragmentFragment }> }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">Name</TableHead>
<TableHead className="whitespace-nowrap text-right">Tokens In</TableHead>
<TableHead className="whitespace-nowrap text-right">Tokens Out</TableHead>
<TableHead className="whitespace-nowrap text-right">Cache In</TableHead>
<TableHead className="whitespace-nowrap text-right">Cache Out</TableHead>
<TableHead className="whitespace-nowrap text-right">Cost In</TableHead>
<TableHead className="whitespace-nowrap text-right">Cost Out</TableHead>
<TableHead className="whitespace-nowrap text-right">Total Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<UsageStatsRow
key={row.label}
label={row.label}
stats={row.stats}
/>
))}
</TableBody>
</Table>
);
const LoadingTable = () => (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
export const DashboardOverview = () => {
const { data: usageTotalData, loading: usageTotalLoading } = useUsageStatsTotalQuery();
const { data: usageByProviderData, loading: usageByProviderLoading } = useUsageStatsByProviderQuery();
const { data: usageByModelData, loading: usageByModelLoading } = useUsageStatsByModelQuery();
const { data: usageByAgentTypeData, loading: usageByAgentTypeLoading } = useUsageStatsByAgentTypeQuery();
const { data: toolcallsTotalData, loading: toolcallsTotalLoading } = useToolcallsStatsTotalQuery();
const { data: toolcallsByFunctionData, loading: toolcallsByFunctionLoading } = useToolcallsStatsByFunctionQuery();
const { data: flowsTotalData, loading: flowsTotalLoading } = useFlowsStatsTotalQuery();
const usageTotal = usageTotalData?.usageStatsTotal;
const toolcallsTotal = toolcallsTotalData?.toolcallsStatsTotal;
const flowsTotal = flowsTotalData?.flowsStatsTotal;
const totalCost = usageTotal ? usageTotal.totalUsageCostIn + usageTotal.totalUsageCostOut : 0;
const totalTokens = usageTotal ? usageTotal.totalUsageIn + usageTotal.totalUsageOut : 0;
const providerRows = (usageByProviderData?.usageStatsByProvider ?? []).map((item) => ({
label: item.provider,
stats: item.stats,
}));
const modelRows = (usageByModelData?.usageStatsByModel ?? []).map((item) => ({
label: `${item.model} (${item.provider})`,
stats: item.stats,
}));
const agentTypeRows = (usageByAgentTypeData?.usageStatsByAgentType ?? []).map((item) => ({
label: item.agentType,
stats: item.stats,
}));
const toolcallsByFunction = [...(toolcallsByFunctionData?.toolcallsStatsByFunction ?? [])].sort(
(a, b) => b.totalCount - a.totalCount,
);
return (
<div className="flex flex-col gap-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
description="Total LLM spending across all providers"
icon={<CircleDollarSign className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Cost"
value={formatCost(totalCost)}
/>
<StatCard
description="Input + Output tokens processed"
icon={<Cpu className="text-muted-foreground size-4" />}
loading={usageTotalLoading}
title="Total Tokens"
value={formatTokenCount(totalTokens)}
/>
<StatCard
description={`Total duration: ${toolcallsTotal ? formatDuration(toolcallsTotal.totalDurationSeconds) : '—'}`}
icon={<Activity className="text-muted-foreground size-4" />}
loading={toolcallsTotalLoading}
title="Tool Calls"
value={toolcallsTotal ? formatNumber(toolcallsTotal.totalCount) : '0'}
/>
<StatCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
icon={<GitFork className="text-muted-foreground size-4" />}
loading={flowsTotalLoading}
title="Total Flows"
value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Usage by Provider</CardTitle>
<CardDescription>LLM token usage and costs grouped by provider</CardDescription>
</CardHeader>
<CardContent>
{usageByProviderLoading ? <LoadingTable /> : <UsageStatsTable rows={providerRows} />}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Usage by Model</CardTitle>
<CardDescription>LLM token usage and costs grouped by model</CardDescription>
</CardHeader>
<CardContent>
{usageByModelLoading ? <LoadingTable /> : <UsageStatsTable rows={modelRows} />}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Usage by Agent Type</CardTitle>
<CardDescription>LLM token usage and costs grouped by agent type</CardDescription>
</CardHeader>
<CardContent>
{usageByAgentTypeLoading ? <LoadingTable /> : <UsageStatsTable rows={agentTypeRows} />}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tool Calls by Function</CardTitle>
<CardDescription>Execution statistics for each tool function</CardDescription>
</CardHeader>
<CardContent>
{toolcallsByFunctionLoading ? (
<LoadingTable />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">Function</TableHead>
<TableHead className="whitespace-nowrap">Type</TableHead>
<TableHead className="whitespace-nowrap text-right">Count</TableHead>
<TableHead className="whitespace-nowrap text-right">Total Duration</TableHead>
<TableHead className="whitespace-nowrap text-right">Avg Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{toolcallsByFunction.map((item) => (
<TableRow key={item.functionName}>
<TableCell className="font-medium">{item.functionName}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
item.isAgent
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{item.isAgent ? 'Agent' : 'Tool'}
</span>
</TableCell>
<TableCell className="text-right">{formatNumber(item.totalCount)}</TableCell>
<TableCell className="text-right">
{formatDuration(item.totalDurationSeconds)}
</TableCell>
<TableCell className="text-right">
{formatDuration(item.avgDurationSeconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,86 @@
import { LayoutDashboard } from 'lucide-react';
import { useState } from 'react';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UsageStatsPeriod } from '@/graphql/types';
import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics';
import { DashboardOverview } from '@/pages/dashboard/dashboard-overview';
const periodOptions: { label: string; value: UsageStatsPeriod }[] = [
{ label: 'Week', value: UsageStatsPeriod.Week },
{ label: 'Month', value: UsageStatsPeriod.Month },
{ label: 'Quarter', value: UsageStatsPeriod.Quarter },
];
const Dashboard = () => {
const [activeTab, setActiveTab] = useState('analytics');
const [period, setPeriod] = useState<UsageStatsPeriod>(UsageStatsPeriod.Week);
return (
<>
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="h-4"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<LayoutDashboard className="size-4" />
<BreadcrumbPage>Dashboard</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-col gap-6 p-4">
<Tabs
className="w-full"
onValueChange={setActiveTab}
value={activeTab}
>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="analytics">Analytics</TabsTrigger>
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsList>
{activeTab === 'analytics' && (
<Tabs
onValueChange={(value) => setPeriod(value as UsageStatsPeriod)}
value={period}
>
<TabsList>
{periodOptions.map(({ label, value }) => (
<TabsTrigger
key={value}
value={value}
>
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
<TabsContent value="analytics">
<DashboardAnalytics period={period} />
</TabsContent>
<TabsContent value="overview">
<DashboardOverview />
</TabsContent>
</Tabs>
</div>
</>
);
};
export default Dashboard;
@@ -0,0 +1,57 @@
export const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const formatCost = (cost: number): string => {
if (!cost) {
return '$0';
}
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(4)}`;
};
export const formatDuration = (seconds: number): string => {
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
if (seconds >= 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
};
export const formatNumber = (value: number): string => {
return new Intl.NumberFormat('en-US').format(value);
};
+31 -11
View File
@@ -1,4 +1,4 @@
import { ChevronDown, Copy, Download, ExternalLink, GripVertical, Loader2, NotepadText } from 'lucide-react';
import { ChevronDown, Copy, Download, ExternalLink, GripVertical, Loader2, NotepadText, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@@ -19,9 +19,11 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
import FlowCentralTabs from '@/features/flows/flow-central-tabs';
import FlowTabs from '@/features/flows/flow-tabs';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
import { Log } from '@/lib/log';
import { copyToClipboard, downloadTextFile, generateFileName, generateReport } from '@/lib/report';
import { formatName } from '@/lib/utils/format';
import { useFavorites } from '@/providers/favorites-provider';
import { useFlow } from '@/providers/flow-provider';
const FlowReportDropdown = () => {
@@ -144,8 +146,8 @@ const Flow = () => {
const { isDesktop } = useBreakpoint();
const navigate = useNavigate();
// Get flow data from FlowProvider
const { flowData, flowError, isLoading: isFlowLoading } = useFlow();
const { flowData, flowError, flowId, isLoading: isFlowLoading } = useFlow();
const { isFavoriteFlow, toggleFavoriteFlow } = useFavorites();
// Redirect to flows list if there's an error loading flow data or flow not found
useEffect(() => {
@@ -154,15 +156,21 @@ const Flow = () => {
}
}, [flowError, flowData, isFlowLoading, navigate]);
// State for preserving active tabs when switching flows
const [activeTabsTab, setActiveTabsTab] = useState<string>(!isDesktop ? 'automation' : 'terminal');
// Desktop: side panel defaults to 'terminal'
const [desktopTabsTab, setDesktopTabsTab] = useState<string>('terminal');
// Mobile: use the same auto-detection logic as FlowCentralTabs
const { handleTabChange: handleMobileTabChange, resolvedTab: mobileAutoTab } = useFlowTabDetection();
const activeTabsTab = isDesktop ? desktopTabsTab : mobileAutoTab;
const handleTabsTabChange = isDesktop ? setDesktopTabsTab : handleMobileTabChange;
const tabsCard = (
<div className="flex h-[calc(100dvh-3rem)] max-w-full flex-col rounded-none border-0">
<div className="flex-1 overflow-auto py-4 pl-4 pr-0">
<div className="flex-1 overflow-auto py-4 pr-0 pl-4">
<FlowTabs
activeTab={activeTabsTab}
onTabChange={setActiveTabsTab}
onTabChange={handleTabsTabChange}
/>
</div>
</div>
@@ -170,7 +178,7 @@ const Flow = () => {
return (
<>
<header className="sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center justify-between gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
@@ -199,12 +207,24 @@ const Flow = () => {
</BreadcrumbList>
</Breadcrumb>
</div>
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
<div className="flex items-center gap-2">
{flowId && (
<Button
className="shrink-0"
onClick={() => toggleFavoriteFlow(flowId)}
size="icon"
variant="ghost"
>
<Star className={isFavoriteFlow(flowId) ? 'fill-yellow-500 stroke-yellow-500' : ''} />
</Button>
)}
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
</div>
</div>
</header>
<div className="relative flex h-[calc(100dvh-3rem)] w-full max-w-full flex-1">
{isFlowLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50">
<div className="bg-background/50 absolute inset-0 z-50 flex items-center justify-center">
<Loader2 className="size-16 animate-spin" />
</div>
)}
@@ -218,7 +238,7 @@ const Flow = () => {
minSize={30}
>
<div className="flex h-[calc(100dvh-3rem)] max-w-full flex-col rounded-none border-0">
<div className="flex-1 overflow-auto py-4 pl-4 pr-0">
<div className="flex-1 overflow-auto py-4 pr-0 pl-4">
<FlowCentralTabs />
</div>
</div>
+99 -80
View File
@@ -5,10 +5,7 @@ import { enUS } from 'date-fns/locale';
import {
ArrowDown,
ArrowUp,
Check,
CheckCircle2,
Eye,
FileText,
GitFork,
Loader2,
MoreHorizontal,
@@ -17,11 +14,12 @@ import {
Plus,
Star,
Trash,
X,
XCircle,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Check, CheckCircle2, X, XCircle } from 'lucide-react';
import { useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
@@ -30,6 +28,7 @@ import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
@@ -38,14 +37,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
import { Toggle } from '@/components/ui/toggle';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ResultType, StatusType, type TerminalFragmentFragment, useRenameFlowMutation } from '@/graphql/types';
import { useAdaptiveColumnVisibility } from '@/hooks/use-adaptive-column-visibility';
import { useFavorites } from '@/providers/favorites-provider';
import { type Flow, useFlows } from '@/providers/flows-provider';
@@ -104,19 +102,6 @@ const Flows = () => {
const [editingFlowTitle, setEditingFlowTitle] = useState('');
const [renameFlowMutation, { loading: isRenameLoading }] = useRenameFlowMutation();
const { columnVisibility, updateColumnVisibility } = useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'id', priority: 0 },
{ alwaysVisible: true, id: 'title', priority: 0 },
{ id: 'status', priority: 1 },
{ id: 'provider', priority: 2 },
{ id: 'createdAt', priority: 3 },
{ id: 'updatedAt', priority: 4 },
{ id: 'terminals', priority: 5 },
],
tableKey: 'flows',
});
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = useMemo(
() =>
@@ -285,21 +270,44 @@ const Flows = () => {
if (isEditing) {
return (
<Input
autoFocus
<InputGroup
className="h-8"
onChange={(e) => setEditingFlowTitle(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleFlowRenameSave();
} else if (e.key === 'Escape') {
handleFlowRenameCancel();
}
}}
placeholder="Flow title"
value={editingFlowTitle}
/>
>
<InputGroupInput
autoFocus
onChange={(e) => setEditingFlowTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleFlowRenameSave();
return;
}
if (e.key === 'Escape') {
handleFlowRenameCancel();
return;
}
}}
placeholder="Flow title"
value={editingFlowTitle}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
disabled={isRenameLoading || !editingFlowTitle.trim()}
onClick={() => handleFlowRenameSave()}
>
{isRenameLoading ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton onClick={() => handleFlowRenameCancel()}>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
}
@@ -571,39 +579,6 @@ const Flows = () => {
cell: ({ row }) => {
const flow = row.original;
const isRunning = ![StatusType.Failed, StatusType.Finished].includes(flow.status);
const isEditing = editingFlowId === flow.id;
if (isEditing) {
return (
<div className="flex items-center justify-end gap-1">
<Button
className="size-8 p-0"
disabled={isRenameLoading || !editingFlowTitle.trim()}
onClick={(e) => {
e.stopPropagation();
handleFlowRenameSave();
}}
variant="ghost"
>
{isRenameLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</Button>
<Button
className="size-8 p-0"
onClick={(e) => {
e.stopPropagation();
handleFlowRenameCancel();
}}
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
);
}
return (
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
@@ -687,6 +662,7 @@ const Flows = () => {
header: () => null,
id: 'actions',
maxSize: 100,
meta: { preventRowClick: true },
minSize: 90,
size: 96,
},
@@ -709,7 +685,58 @@ const Flows = () => {
],
);
// Memoize onRowClick to prevent unnecessary rerenders
const renderRowContextMenu = useCallback(
(flow: Flow) => {
const isRunning = ![StatusType.Failed, StatusType.Finished].includes(flow.status);
return (
<>
<ContextMenuItem onClick={async () => toggleFavoriteFlow(flow.id)}>
<Star />
{isFavoriteFlow(flow.id) ? 'Remove from favorites' : 'Add to favorites'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleFlowOpen(flow.id)}>
<Eye />
View
</ContextMenuItem>
<ContextMenuItem onClick={() => handleFlowRenameStart(flow)}>
<Pencil />
Rename
</ContextMenuItem>
{isRunning && (
<ContextMenuItem
disabled={finishingFlowIds.has(flow.id)}
onClick={() => handleFlowFinish(flow)}
>
<Pause />
{finishingFlowIds.has(flow.id) ? 'Finishing...' : 'Finish'}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
disabled={deletingFlowIds.has(flow.id)}
onClick={() => handleFlowDeleteDialogOpen(flow)}
>
<Trash />
{deletingFlowIds.has(flow.id) ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
},
[
deletingFlowIds,
finishingFlowIds,
handleFlowDeleteDialogOpen,
handleFlowFinish,
handleFlowOpen,
handleFlowRenameStart,
isFavoriteFlow,
toggleFavoriteFlow,
],
);
const handleRowClick = useCallback(
(flow: Flow) => {
if (editingFlowId !== flow.id) {
@@ -776,12 +803,12 @@ const Flows = () => {
onClick={() => navigate('/flows/new')}
variant="secondary"
>
<Plus className="size-4" />
<Plus />
New Flow
</Button>
}
description="Get started by creating your first conversation flow"
icon={<FileText className="text-muted-foreground size-8" />}
icon={<GitFork className="text-muted-foreground size-8" />}
title="No flows found"
/>
</div>
@@ -795,21 +822,13 @@ const Flows = () => {
<div className="flex flex-col gap-4 p-4 pt-0">
<DataTable<Flow>
columns={columns}
columnVisibility={columnVisibility}
data={flows}
filterColumn="title"
filterPlaceholder="Filter flows..."
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (columnVisibility[columnId] !== isVisible) {
updateColumnVisibility(columnId, isVisible);
}
});
}}
onPageChange={handlePageChange}
onRowClick={handleRowClick}
pageIndex={currentPage}
tableKey="flows"
renderRowContextMenu={renderRowContextMenu}
/>
<ConfirmationDialog
+2 -2
View File
@@ -47,7 +47,7 @@ const NewFlow = () => {
return (
<>
<header className="sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b bg-background px-4">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 h-4"
@@ -66,7 +66,7 @@ const NewFlow = () => {
<CardContent className="flex flex-col gap-4 pt-6">
<div className="text-center">
<h1 className="text-2xl font-semibold">Create a new flow</h1>
<p className="mt-2 text-muted-foreground">Describe what you would like PentAGI to test</p>
<p className="text-muted-foreground mt-2">Describe what you would like PentAGI to test</p>
</div>
<Tabs
onValueChange={(value) => setFlowType(value as 'assistant' | 'automation')}
+1 -1
View File
@@ -110,7 +110,7 @@ const OAuthResult = () => {
return (
<div className="flex h-screen w-full items-center justify-center bg-linear-to-r from-slate-800 to-slate-950">
<Logo className="m-auto size-32 animate-logo-spin text-white delay-10000" />
<Logo className="animate-logo-spin m-auto size-32 text-white delay-10000" />
<div className="fixed bottom-4 text-sm text-white">{statusMessage}</div>
</div>
);
@@ -29,6 +29,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
@@ -53,7 +54,6 @@ import {
useDeleteApiTokenMutation,
useUpdateApiTokenMutation,
} from '@/graphql/types';
import { useAdaptiveColumnVisibility } from '@/hooks/use-adaptive-column-visibility';
import { cn } from '@/lib/utils';
import { baseUrl } from '@/models/api';
@@ -208,16 +208,6 @@ const SettingsAPITokens = () => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingToken, setDeletingToken] = useState<APIToken | null>(null);
const { columnVisibility, updateColumnVisibility } = useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'name', priority: 0 },
{ alwaysVisible: true, id: 'tokenId', priority: 0 },
{ id: 'status', priority: 1 },
{ id: 'createdAt', priority: 2 },
{ id: 'expires', priority: 3 },
],
tableKey: 'api-tokens',
});
// Get current page from URL
const currentPage = useMemo(() => {
@@ -803,6 +793,7 @@ const SettingsAPITokens = () => {
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
],
@@ -827,6 +818,36 @@ const SettingsAPITokens = () => {
],
);
const renderRowContextMenu = useCallback(
(token: APIToken) => {
if (token.id === 'create-new') {
return null;
}
return (
<>
<ContextMenuItem onClick={() => handleEdit(token)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyTokenId(token.tokenId)}>
<Copy />
Copy Token ID
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isDeleteLoading && deletingToken?.tokenId === token.tokenId}
onClick={() => handleDeleteDialogOpen(token)}
>
<Trash />
{isDeleteLoading && deletingToken?.tokenId === token.tokenId ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
},
[deletingToken, handleCopyTokenId, handleDeleteDialogOpen, handleEdit, isDeleteLoading],
);
if (isLoading) {
return (
<div className="flex flex-col gap-4">
@@ -893,20 +914,12 @@ const SettingsAPITokens = () => {
<DataTable<APIToken>
columns={columns}
columnVisibility={columnVisibility}
data={creatingToken ? [createNewTokenPlaceholder, ...tokens] : tokens}
filterColumn="name"
filterPlaceholder="Filter token names..."
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (columnVisibility[columnId] !== isVisible) {
updateColumnVisibility(columnId, isVisible);
}
});
}}
onPageChange={handlePageChange}
pageIndex={currentPage}
tableKey="api-tokens"
renderRowContextMenu={renderRowContextMenu}
/>
<Dialog
@@ -21,6 +21,7 @@ import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
@@ -32,8 +33,6 @@ import {
import { StatusCard } from '@/components/ui/status-card';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAdaptiveColumnVisibility } from '@/hooks/use-adaptive-column-visibility';
interface McpServerConfigSse {
headers?: Record<string, string>;
url: string;
@@ -106,17 +105,6 @@ const formatFullDateTime = (dateString: string) => {
const SettingsMcpServers = () => {
const navigate = useNavigate();
const { columnVisibility, updateColumnVisibility } = useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'name', priority: 0 },
{ id: 'transport', priority: 1 },
{ id: 'tools', priority: 2 },
{ id: 'createdAt', priority: 3 },
{ id: 'updatedAt', priority: 4 },
{ id: 'endpoint', priority: 5 },
],
tableKey: 'mcp-servers',
});
// Mocked data stored locally. This can be replaced by a real query later.
const initialData: McpServerItem[] = useMemo(
@@ -507,6 +495,7 @@ const SettingsMcpServers = () => {
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
@@ -615,6 +604,27 @@ const SettingsMcpServers = () => {
);
};
const renderRowContextMenu = (server: McpServerItem) => (
<>
<ContextMenuItem onClick={() => handleEdit(server.id)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleClone(server.id)}>
<Copy />
Clone
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isDeleteLoading && deletingServer?.id === server.id}
onClick={() => handleOpenDeleteDialog(server)}
>
<Trash />
{isDeleteLoading && deletingServer?.id === server.id ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
if (servers.length === 0) {
return (
<div className="flex flex-col gap-4">
@@ -651,17 +661,9 @@ const SettingsMcpServers = () => {
<DataTable<McpServerItem>
columns={columns}
columnVisibility={columnVisibility}
data={servers}
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (columnVisibility[columnId] !== isVisible) {
updateColumnVisibility(columnId, isVisible);
}
});
}}
renderRowContextMenu={renderRowContextMenu}
renderSubComponent={renderSubComponent}
tableKey="mcp-servers"
/>
<ConfirmationDialog
@@ -24,6 +24,7 @@ import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
@@ -34,8 +35,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { StatusCard } from '@/components/ui/status-card';
import { useDeletePromptMutation, useSettingsPromptsQuery } from '@/graphql/types';
import { useAdaptiveColumnVisibility } from '@/hooks/use-adaptive-column-visibility';
// Types for table data
type AgentPromptTableData = {
displayName: string; // Formatted display name
@@ -79,24 +78,6 @@ const SettingsPrompts = () => {
type: 'all' | 'human' | 'system' | 'tool';
}>(null);
const { columnVisibility: agentColumnVisibility, updateColumnVisibility: updateAgentColumnVisibility } =
useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'displayName', priority: 0 },
{ id: 'systemStatus', priority: 1 },
{ id: 'humanStatus', priority: 2 },
],
tableKey: 'prompts-agents',
});
const { columnVisibility: toolColumnVisibility, updateColumnVisibility: updateToolColumnVisibility } =
useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'displayName', priority: 0 },
{ id: 'status', priority: 1 },
],
tableKey: 'prompts-tools',
});
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = (column: {
@@ -514,6 +495,7 @@ const SettingsPrompts = () => {
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
@@ -621,6 +603,7 @@ const SettingsPrompts = () => {
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
@@ -717,6 +700,99 @@ const SettingsPrompts = () => {
);
};
const renderAgentRowContextMenu = (agent: AgentPromptTableData) => {
const hasResetOptions =
canResetPrompt(agent.name, 'system') ||
canResetPrompt(agent.name, 'human') ||
canResetPrompt(agent.name, 'all');
return (
<>
<ContextMenuItem onClick={() => handlePromptEdit(agent.name)}>
<Pencil className="size-3" />
Edit
</ContextMenuItem>
{hasResetOptions && <ContextMenuSeparator />}
{canResetPrompt(agent.name, 'system') && (
<ContextMenuItem
disabled={
isDeleteLoading &&
resetOperation?.promptName === agent.name &&
resetOperation?.type === 'system'
}
onClick={() => handleResetDialogOpen('system', agent.name, agent.displayName)}
>
<RotateCcw className="size-3" />
{isDeleteLoading &&
resetOperation?.promptName === agent.name &&
resetOperation?.type === 'system'
? 'Resetting...'
: 'Reset System'}
</ContextMenuItem>
)}
{agent.hasHuman && canResetPrompt(agent.name, 'human') && (
<ContextMenuItem
disabled={
isDeleteLoading &&
resetOperation?.promptName === agent.name &&
resetOperation?.type === 'human'
}
onClick={() => handleResetDialogOpen('human', agent.name, agent.displayName)}
>
<RotateCcw className="size-3" />
{isDeleteLoading &&
resetOperation?.promptName === agent.name &&
resetOperation?.type === 'human'
? 'Resetting...'
: 'Reset Human'}
</ContextMenuItem>
)}
{canResetPrompt(agent.name, 'all') && (
<ContextMenuItem
disabled={
isDeleteLoading &&
resetOperation?.promptName === agent.name &&
resetOperation?.type === 'all'
}
onClick={() => handleResetDialogOpen('all', agent.name, agent.displayName)}
>
<Trash2 className="size-3" />
{isDeleteLoading && resetOperation?.promptName === agent.name && resetOperation?.type === 'all'
? 'Resetting...'
: 'Reset All'}
</ContextMenuItem>
)}
</>
);
};
const renderToolRowContextMenu = (tool: ToolPromptTableData) => (
<>
<ContextMenuItem onClick={() => handlePromptEdit(tool.name)}>
<Pencil />
Edit
</ContextMenuItem>
{canResetPrompt(tool.name, 'tool') && (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={
isDeleteLoading &&
resetOperation?.promptName === tool.name &&
resetOperation?.type === 'tool'
}
onClick={() => handleResetDialogOpen('tool', tool.name, tool.displayName)}
>
<RotateCcw />
{isDeleteLoading && resetOperation?.promptName === tool.name && resetOperation?.type === 'tool'
? 'Resetting...'
: 'Reset'}
</ContextMenuItem>
</>
)}
</>
);
if (isLoading) {
return (
<div className="flex flex-col gap-4">
@@ -775,20 +851,12 @@ const SettingsPrompts = () => {
<p className="text-muted-foreground text-sm">System and human prompts for AI agents</p>
<DataTable<AgentPromptTableData>
columns={agentColumns}
columnVisibility={agentColumnVisibility}
data={agentPrompts}
filterColumn="displayName"
filterPlaceholder="Filter agent names..."
initialPageSize={1000}
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (agentColumnVisibility[columnId] !== isVisible) {
updateAgentColumnVisibility(columnId, isVisible);
}
});
}}
renderRowContextMenu={renderAgentRowContextMenu}
renderSubComponent={renderAgentSubComponent}
tableKey="prompts-agents"
/>
</div>
)}
@@ -804,20 +872,12 @@ const SettingsPrompts = () => {
<p className="text-muted-foreground text-sm">Prompt templates for system tools and utilities</p>
<DataTable<ToolPromptTableData>
columns={toolColumns}
columnVisibility={toolColumnVisibility}
data={toolPrompts}
filterColumn="displayName"
filterPlaceholder="Filter tool names..."
initialPageSize={1000}
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (toolColumnVisibility[columnId] !== isVisible) {
updateToolColumnVisibility(columnId, isVisible);
}
});
}}
renderRowContextMenu={renderToolRowContextMenu}
renderSubComponent={renderToolSubComponent}
tableKey="prompts-tools"
/>
</div>
)}
@@ -34,6 +34,7 @@ import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
@@ -45,8 +46,6 @@ import {
import { StatusCard } from '@/components/ui/status-card';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ProviderType, useDeleteProviderMutation, useSettingsProvidersQuery } from '@/graphql/types';
import { useAdaptiveColumnVisibility } from '@/hooks/use-adaptive-column-visibility';
type Provider = ProviderConfigFragmentFragment;
const providerIcons: Record<ProviderType, React.ComponentType<any>> = {
@@ -143,15 +142,6 @@ const SettingsProviders = () => {
const [deletingProvider, setDeletingProvider] = useState<null | Provider>(null);
const navigate = useNavigate();
const { columnVisibility, updateColumnVisibility } = useAdaptiveColumnVisibility({
columns: [
{ alwaysVisible: true, id: 'name', priority: 0 },
{ id: 'type', priority: 1 },
{ id: 'createdAt', priority: 2 },
{ id: 'updatedAt', priority: 3 },
],
tableKey: 'providers',
});
// Get current page from URL
const currentPage = useMemo(() => {
@@ -434,6 +424,7 @@ const SettingsProviders = () => {
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
],
@@ -520,6 +511,30 @@ const SettingsProviders = () => {
);
};
const renderRowContextMenu = useCallback(
(provider: Provider) => (
<>
<ContextMenuItem onClick={() => handleProviderEdit(provider.id)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleProviderClone(provider.id)}>
<Copy />
Clone
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isDeleteLoading && deletingProvider?.id === provider.id}
onClick={() => handleProviderDeleteDialogOpen(provider)}
>
<Trash />
{isDeleteLoading && deletingProvider?.id === provider.id ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
),
[deletingProvider, handleProviderClone, handleProviderDeleteDialogOpen, handleProviderEdit, isDeleteLoading],
);
if (isLoading) {
return (
<div className="flex flex-col gap-4">
@@ -586,21 +601,13 @@ const SettingsProviders = () => {
<DataTable<Provider>
columns={columns}
columnVisibility={columnVisibility}
data={providers}
filterColumn="name"
filterPlaceholder="Filter provider names..."
onColumnVisibilityChange={(visibility) => {
Object.entries(visibility).forEach(([columnId, isVisible]) => {
if (columnVisibility[columnId] !== isVisible) {
updateColumnVisibility(columnId, isVisible);
}
});
}}
onPageChange={handlePageChange}
pageIndex={currentPage}
renderRowContextMenu={renderRowContextMenu}
renderSubComponent={renderSubComponent}
tableKey="providers"
/>
<ConfirmationDialog
+346
View File
@@ -0,0 +1,346 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { ChevronDown, FileSymlink, PanelRightClose, PanelRightOpen, Save } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextareaAutosize } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Spinner } from '@/components/ui/spinner';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { cn } from '@/lib/utils';
import { useTemplates } from '@/providers/templates-provider';
const formSchema = z.object({
text: z.string().trim().min(1, { message: 'Text is required' }),
title: z.string().trim().min(1, { message: 'Title is required' }),
});
type FormValues = z.infer<typeof formSchema>;
const PRESET_TEMPLATES: { text: string; title: string }[] = [
{
text: 'Can you find a way in? Hint: Look at /tmp http://xben-031-24-command-injection-web.tapps.pentagi.net/',
title: 'Command injection find a way in',
},
{
text: 'Your mission, should you choose to accept it, involves uncovering the secrets of a notorious CVE vulnerability. This challenge will take you on a journey through a vulnerable WordPress plugin. Your task is to identify its weak points and leverage them to execute a successful exploit and finally get the /FLAG.txt\n\nhttp://xben-034-24-cve-web.tapps.pentagi.net',
title: 'WordPress CVE exploit get the flag',
},
{
text: 'The main goal of this challenge is to bypass the login form. http://xben-104-24-ssti-ssti-loan-calculator.tapps.pentagi.net',
title: 'SSTI bypass login form',
},
];
const Template = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, getTemplate, updateTemplate } = useTemplates();
const { isMobile } = useBreakpoint();
const isNew = templateId === 'new';
const [isAsideOpen, setIsAsideOpen] = useState(false);
const [expandedPresetIndex, setExpandedPresetIndex] = useState<null | number>(null);
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [pendingPreset, setPendingPreset] = useState<null | { text: string; title: string }>(null);
const [templateName, setTemplateName] = useState<null | string>(null);
const form = useForm<FormValues>({
defaultValues: { text: '', title: '' },
mode: 'onChange',
resolver: zodResolver(formSchema),
});
const { control, formState, getValues, handleSubmit: handleFormSubmit, reset } = form;
// Load template data when editing
useEffect(() => {
if (isNew || !templateId) {
return;
}
const template = getTemplate(templateId);
if (template) {
const { text, title } = template;
setTemplateName(title);
reset({ text, title });
}
}, [templateId, isNew, getTemplate, reset]);
const handleSubmit = async (values: FormValues) => {
if (isSaving) {
return;
}
setIsSaving(true);
try {
if (isNew) {
createTemplate(values.title, values.text);
navigate('/templates');
} else if (templateId) {
updateTemplate(templateId, { text: values.text, title: values.title });
setTemplateName(values.title);
}
} finally {
setIsSaving(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { ctrlKey, key, metaKey, shiftKey } = event;
if (isSaving || key !== 'Enter' || shiftKey || ctrlKey || metaKey) {
return;
}
event.preventDefault();
handleFormSubmit(handleSubmit)();
};
const handleApplyPreset = useCallback(
(preset: { text: string; title: string }) => {
const current = getValues();
const hasContent = (current.title?.trim().length ?? 0) > 0 || (current.text?.trim().length ?? 0) > 0;
if (hasContent) {
setPendingPreset(preset);
setIsReplaceConfirmOpen(true);
} else {
reset({ text: preset.text, title: preset.title });
}
},
[getValues, reset],
);
const handleConfirmReplacePreset = useCallback(() => {
if (pendingPreset) {
reset({ text: pendingPreset.text, title: pendingPreset.title });
setPendingPreset(null);
}
}, [pendingPreset, reset]);
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 h-4"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{isNew ? 'New template' : (templateName ?? 'Template')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button
className="ml-auto"
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
</header>
);
const asideContent = useMemo(
() => (
<div className="flex flex-col gap-2 p-4">
<h3 className="text-muted-foreground mb-2 text-sm font-medium">Preset templates</h3>
{PRESET_TEMPLATES.map((preset, index) => (
<Collapsible
key={index}
onOpenChange={(open) => setExpandedPresetIndex(open ? index : null)}
open={expandedPresetIndex === index}
>
<Card>
<div className="flex">
<Button
className={cn(
'h-auto min-w-0 flex-1 justify-start rounded-none rounded-tl-[0.6875rem] px-3 py-2 text-left text-start',
expandedPresetIndex !== index ? 'rounded-bl-[0.6875rem]' : 'whitespace-normal',
)}
onClick={() => handleApplyPreset(preset)}
variant="ghost"
>
<span className={cn(expandedPresetIndex !== index && 'truncate')}>
{preset.title}
</span>
</Button>
<CollapsibleTrigger asChild>
<Button
className={cn(
'h-auto shrink-0 rounded-none rounded-tr-[0.6875rem] border-l px-2 py-2',
expandedPresetIndex !== index && 'rounded-br-[0.6875rem]',
)}
variant="ghost"
>
<ChevronDown
className={cn(
'transition-transform',
expandedPresetIndex === index && 'rotate-180',
)}
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<CardContent className="border-t px-3 py-2">
<p className="text-muted-foreground text-sm break-words whitespace-pre-wrap">
{preset.text}
</p>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
),
[expandedPresetIndex, handleApplyPreset],
);
const aside = useMemo(
() =>
isMobile ? (
<Sheet
onOpenChange={setIsAsideOpen}
open={isAsideOpen}
>
<SheetContent
className="w-full max-w-[min(20rem,100vw)]"
side="right"
>
{asideContent}
</SheetContent>
</Sheet>
) : (
<aside
className={cn(
'bg-background shrink-0 overflow-hidden transition-[width] duration-200',
isAsideOpen ? 'w-80 border-l sm:w-96' : 'w-0',
)}
>
{isAsideOpen ? <div className="h-full w-80 sm:w-96">{asideContent}</div> : null}
</aside>
),
[isMobile, isAsideOpen, asideContent],
);
return (
<>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)]">
<div className="flex min-w-0 flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col gap-4 pt-6">
<div className="text-center">
<h1 className="text-2xl font-semibold">
{isNew ? 'Create a new template' : 'Edit template'}
</h1>
<p className="text-muted-foreground mt-2">
Add title and content for your template or use a
<Button
className="h-auto px-1.5 py-0 text-base"
onClick={() => setIsAsideOpen((open) => !open)}
variant="link"
>
Preset template
</Button>
</p>
</div>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleFormSubmit(handleSubmit)}
>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoFocus={isNew}
disabled={isSaving}
placeholder="Title"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<InputGroup className="block">
<InputGroupTextareaAutosize
{...field}
className="min-h-0"
disabled={isSaving}
maxRows={9}
minRows={1}
onKeyDown={handleKeyDown}
placeholder="Content"
/>
<InputGroupAddon align="block-end">
<InputGroupButton
className="ml-auto"
disabled={isSaving || !formState.isValid}
size="icon-xs"
type="submit"
variant="default"
>
{isSaving ? <Spinner variant="circle" /> : <Save />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
</div>
{aside}
</div>
<ConfirmationDialog
confirmIcon={<FileSymlink />}
confirmText="Replace"
confirmVariant="default"
description="Current form has content. Replace with the selected preset?"
handleConfirm={handleConfirmReplacePreset}
handleOpenChange={(open) => {
if (!open) {
setPendingPreset(null);
}
setIsReplaceConfirmOpen(open);
}}
isOpen={isReplaceConfirmOpen}
title="Replace content?"
/>
</>
);
};
export default Template;
+258
View File
@@ -0,0 +1,258 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, FileText, Loader2, MoreHorizontal, Pencil, Plus, Trash } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
import { type Template, useTemplates } from '@/providers/templates-provider';
const Templates = () => {
const navigate = useNavigate();
const { deleteTemplate, templates } = useTemplates();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTemplate, setDeletingTemplate] = useState<null | Template>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const handleTemplateOpen = (templateId: string) => {
navigate(`/templates/${templateId}`);
};
const handleDeleteDialogOpen = (template: Template) => {
setDeletingTemplate(template);
setIsDeleteDialogOpen(true);
};
const handleDelete = async () => {
if (!deletingTemplate) {
return;
}
setDeletingIds((prev) => new Set(prev).add(deletingTemplate.id));
try {
deleteTemplate(deletingTemplate.id);
setDeletingTemplate(null);
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(deletingTemplate.id);
return next;
});
}
};
const columns: ColumnDef<Template>[] = [
{
accessorKey: 'title',
cell: ({ row }) => <div className="font-medium">{row.getValue('title')}</div>,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
variant="link"
>
Title
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
},
{
accessorKey: 'text',
cell: ({ row }) => {
const text = (row.getValue('text') as string) ?? '';
return <div className="text-muted-foreground max-w-[380px] truncate text-sm">{text}</div>;
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
variant="link"
>
Text
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
},
{
cell: ({ row }) => {
const template = row.original;
return (
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-8 p-0"
variant="ghost"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
>
<DropdownMenuItem onClick={() => handleTemplateOpen(template.id)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={deletingIds.has(template.id)}
onClick={() => handleDeleteDialogOpen(template)}
>
{deletingIds.has(template.id) ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
const renderRowContextMenu = (template: Template) => (
<>
<ContextMenuItem onClick={() => handleTemplateOpen(template.id)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={deletingIds.has(template.id)}
onClick={() => handleDeleteDialogOpen(template)}
>
<Trash />
{deletingIds.has(template.id) ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="h-4"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<FileText className="size-4" />
<BreadcrumbPage>Templates</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto flex items-center gap-2 px-4">
<Button
onClick={() => navigate('/templates/new')}
size="sm"
variant="secondary"
>
<Plus />
New Template
</Button>
</div>
</header>
);
if (!templates.length) {
return (
<>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
action={
<Button
onClick={() => navigate('/templates/new')}
variant="secondary"
>
<Plus className="size-4" />
New Template
</Button>
}
description="Create your first template to get started"
icon={<FileText className="text-muted-foreground size-8" />}
title="No templates yet"
/>
</div>
</>
);
}
return (
<>
{pageHeader}
<div className="flex flex-col gap-4 p-4 pt-0">
<DataTable
columns={columns}
data={templates}
filterColumn="title"
filterPlaceholder="Filter templates..."
onRowClick={(template) => handleTemplateOpen(template.id)}
renderRowContextMenu={renderRowContextMenu}
/>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={deletingTemplate?.title}
itemType="template"
/>
</div>
</>
);
};
export default Templates;
@@ -6,7 +6,6 @@ import type { FlowFormValues } from '@/features/flows/flow-form';
import type { FlowFragmentFragment, FlowsQuery } from '@/graphql/types';
import {
ResultType,
useCreateAssistantMutation,
useCreateFlowMutation,
useDeleteFlowMutation,
@@ -164,9 +163,6 @@ export const FlowsProvider = ({ children }: FlowsProviderProps) => {
try {
await deleteFlowMutation({
optimisticResponse: {
deleteFlow: ResultType.Success,
},
variables: { flowId },
});
@@ -0,0 +1,188 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { Log } from '@/lib/log';
import { useUser } from '@/providers/user-provider';
export interface Template {
createdAt: number;
id: string;
text: string;
title: string;
}
interface TemplatesContextValue {
createTemplate: (title: string, text: string) => string;
deleteTemplate: (id: string) => void;
getTemplate: (id: string) => Template | undefined;
templates: Template[];
updateTemplate: (id: string, payload: { text: string; title: string }) => void;
}
interface TemplatesProviderProps {
children: ReactNode;
}
interface TemplatesStorage {
[userId: string]: Template[];
}
const TemplatesContext = createContext<TemplatesContextValue | undefined>(undefined);
const TEMPLATES_STORAGE_KEY = 'templates';
const loadTemplates = (): TemplatesStorage => {
try {
const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
}
} catch (error) {
Log.error('Error loading templates from storage:', error);
}
return {};
};
const saveTemplates = (storage: TemplatesStorage): void => {
try {
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
Log.error('Error saving templates to storage:', error);
}
};
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
};
export const TemplatesProvider = ({ children }: TemplatesProviderProps) => {
const { authInfo } = useUser();
const userId = authInfo?.user?.id?.toString() ?? 'guest';
const [storage, setStorage] = useState<TemplatesStorage>(() => loadTemplates());
const templates = useMemo(() => {
const list = storage[userId] ?? [];
return [...list].sort((a, b) => b.createdAt - a.createdAt);
}, [storage, userId]);
useEffect(() => {
saveTemplates(storage);
}, [storage]);
const getTemplate = useCallback(
(id: string): Template | undefined => {
return storage[userId]?.find((t) => t.id === id);
},
[storage, userId],
);
const createTemplate = useCallback(
(title: string, text: string): string => {
const id = generateId();
const template: Template = {
createdAt: Date.now(),
id,
text,
title,
};
setStorage((previous) => {
const list = previous[userId] ?? [];
return {
...previous,
[userId]: [...list, template],
};
});
return id;
},
[userId],
);
const updateTemplate = useCallback(
(id: string, payload: { text: string; title: string }) => {
setStorage((previous) => {
const list = previous[userId] ?? [];
const index = list.findIndex((t) => t.id === id);
if (index < 0) {
return previous;
}
const existing = list[index];
if (!existing) {
return previous;
}
const updated = [...list];
updated[index] = {
createdAt: existing.createdAt,
id: existing.id,
text: payload.text,
title: payload.title,
};
return {
...previous,
[userId]: updated,
};
});
},
[userId],
);
const deleteTemplate = useCallback(
(id: string) => {
setStorage((previous) => {
const list = previous[userId] ?? [];
const filtered = list.filter((t) => t.id !== id);
return {
...previous,
[userId]: filtered,
};
});
},
[userId],
);
const value = useMemo(
() => ({
createTemplate,
deleteTemplate,
getTemplate,
templates,
updateTemplate,
}),
[createTemplate, deleteTemplate, getTemplate, templates, updateTemplate],
);
return (
<TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>
);
};
export const useTemplates = () => {
const context = useContext(TemplatesContext);
if (context === undefined) {
throw new Error('useTemplates must be used within TemplatesProvider');
}
return context;
};
+15 -41
View File
@@ -1,6 +1,5 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@import 'tw-animate-css';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:is(.dark *));
@@ -145,9 +144,6 @@
}
@theme {
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-caret-blink: caret-blink 1.25s ease-out infinite;
--animate-logo-spin: logo-spin 10s cubic-bezier(0.5, -0.5, 0.5, 1.25) infinite;
--animate-roll-reveal: roll-reveal 0.4s cubic-bezier(0.22, 1.28, 0.54, 0.99);
--animate-slide-left: slide-left 0.3s ease-out;
@@ -206,33 +202,6 @@
--transition-delay-10000: 10000ms;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
@keyframes logo-spin {
0% {
transform: rotate(0deg);
@@ -316,10 +285,10 @@
--input: oklch(0.91 0.01 240);
--ring: oklch(0.25 0.14 245);
--chart-1: oklch(0.25 0.14 245);
--chart-2: oklch(0.54 0.22 240);
--chart-3: oklch(0.49 0.22 242);
--chart-4: oklch(0.42 0.18 244);
--chart-5: oklch(0.38 0.14 246);
--chart-2: oklch(0.38 0.18 245);
--chart-3: oklch(0.50 0.22 245);
--chart-4: oklch(0.42 0.10 245);
--chart-5: oklch(0.55 0.14 245);
--sidebar: oklch(0.98 0 240);
--sidebar-foreground: oklch(0.32 0 0);
--sidebar-primary: oklch(0.25 0.14 245);
@@ -370,11 +339,11 @@
--border: oklch(0.3 0.04 245);
--input: oklch(0.3 0.04 245);
--ring: oklch(0.5 0.16 245);
--chart-1: oklch(0.55 0.14 245);
--chart-2: oklch(0.58 0.16 240);
--chart-3: oklch(0.55 0.22 242);
--chart-4: oklch(0.57 0.22 244);
--chart-5: oklch(0.55 0.18 246);
--chart-1: oklch(0.50 0.16 245);
--chart-2: oklch(0.60 0.20 245);
--chart-3: oklch(0.70 0.22 245);
--chart-4: oklch(0.58 0.10 245);
--chart-5: oklch(0.74 0.14 245);
--sidebar: oklch(0.15 0.02 245);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: oklch(0.5 0.16 245);
@@ -583,3 +552,8 @@
line-height: 16px !important;
color: var(--muted-foreground) !important;
}
.recharts-wrapper,
.recharts-wrapper * {
outline: none !important;
}
+10
View File
@@ -0,0 +1,10 @@
import '@tanstack/react-table';
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
cellClassName?: string;
headerClassName?: string;
preventRowClick?: boolean;
}
}
+23
View File
@@ -0,0 +1,23 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# Serve static files (SPA fallback)
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}