chore: add pnpm workspace configuration with package definitions and overrides
@@ -0,0 +1,8 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/.yarn/** linguist-vendored
|
||||||
|
/.yarn/releases/* binary
|
||||||
|
/.yarn/plugins/**/* binary
|
||||||
|
/.pnp.* binary linguist-generated
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# --- secrets (never commit) ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# --- rust (worker workspace) ---
|
||||||
|
/target
|
||||||
|
|
||||||
|
# --- node / frontend ---
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.vite/
|
||||||
|
|
||||||
|
# --- iii runtime artifacts ---
|
||||||
|
.iii/
|
||||||
|
iii-data/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# --- crash dumps ---
|
||||||
|
core
|
||||||
|
core.*
|
||||||
|
|
||||||
|
# --- design tool scratch (@astryxdesign/cli) ---
|
||||||
|
apps/web-app/.astryx-tmp/
|
||||||
|
|
||||||
|
# --- yarn berry install state ---
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# --- editor/OS ---
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
yarnPath: .yarn/releases/yarn-4.18.0.cjs
|
||||||
|
nodeLinker: node-modules
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!--VITE PLUS START-->
|
||||||
|
|
||||||
|
# Using Vite+, the Unified Toolchain for the Web
|
||||||
|
|
||||||
|
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
|
||||||
|
|
||||||
|
Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/.
|
||||||
|
|
||||||
|
## Built-in Commands vs Scripts
|
||||||
|
|
||||||
|
`vp <name>` runs a built-in command. `vp run <name>` runs a `package.json` script or a `vite.config.ts` task. Scripts cannot overwrite built-ins, so `vp dev` and `vp run dev` may do different things. Check `package.json` and `vite.config.ts` first, and run `vp run <name>` when the project defines a script or task with that name.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Run `vp install` after pulling remote changes and before getting started.
|
||||||
|
- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes.
|
||||||
|
- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run <script>`.
|
||||||
|
- [ ] If setup, runtime, or package-manager behavior looks wrong, run `vp env doctor` and include its output when asking for help.
|
||||||
|
|
||||||
|
<!--VITE PLUS END-->
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Base URL of the Better Auth API (Cloudflare Worker).
|
||||||
|
# Defaults to http://localhost:8787 when unset.
|
||||||
|
VITE_AUTH_URL=http://localhost:8787
|
||||||
|
|
||||||
|
# Display name used across the app (defaults to "Trinity").
|
||||||
|
VITE_APP_NAME=Trinity
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Project-specific guidance for AI coding agents.
|
||||||
|
|
||||||
|
<!-- ASTRYX:START -->
|
||||||
|
|
||||||
|
Astryx v0.3.0 · 155 components
|
||||||
|
CLI: run every command as `pnpm exec astryx <cmd>` (shown below as `astryx ...`).
|
||||||
|
|
||||||
|
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
|
||||||
|
import "@astryxdesign/core/reset.css";
|
||||||
|
import "@astryxdesign/core/astryx.css";
|
||||||
|
|
||||||
|
WORKFLOW — discover, don't guess. Before writing UI:
|
||||||
|
|
||||||
|
1. `astryx build "<idea>"` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.
|
||||||
|
2. `astryx template <name> [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.
|
||||||
|
3. `astryx component <Name>` — props + examples for every component you use.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
|
||||||
|
- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
|
||||||
|
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
|
||||||
|
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
|
||||||
|
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
|
||||||
|
- Custom styling: component props first; else style/className with tokens — var(--color-_|--spacing-_|--radius-*). No raw hex/px. (No StyleX/Tailwind compiler here — don't use xstyle/utility classes.)
|
||||||
|
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.
|
||||||
|
- SELF-CHECK before you finish: re-read the file and replace any raw <div>/<span> layout, imported .css/@apply, or hardcoded value (#hex, 16px) with the component or a token (var(--color-_|--spacing-_|…)). If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.
|
||||||
|
|
||||||
|
MORE CLI:
|
||||||
|
search "<query>" find any component / hook / doc / template / block
|
||||||
|
component --list 155 components by category
|
||||||
|
template --list page + block recipes
|
||||||
|
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
|
||||||
|
swizzle <Name> eject component source for deep customization
|
||||||
|
upgrade --apply run after any @astryxdesign/core bump
|
||||||
|
<!-- ASTRYX:END -->
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Attendance</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,[email protected],100..1000&family=JetBrains+Mono:[email protected]&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "attendance-web-app",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vp dev",
|
||||||
|
"build": "tsc && vp build",
|
||||||
|
"preview": "vp preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astryxdesign/core": "^0.3.0",
|
||||||
|
"@astryxdesign/theme-neutral": "^0.3.0",
|
||||||
|
"@dagrejs/dagre": "^3.1.1",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@stylexjs/stylex": "^0.19.0",
|
||||||
|
"@tabler/icons-react": "^3.46.0",
|
||||||
|
"@tanstack/react-form": "^1.33.3",
|
||||||
|
"@wterm/dom": "^0.3.3",
|
||||||
|
"@wterm/react": "^0.3.3",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"better-auth": "1.6.26",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"monaco-editor": "^0.56.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router": "^8.3.0",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astryxdesign/cli": "^0.3.0",
|
||||||
|
"@stylexjs/unplugin": "^0.19.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "catalog:",
|
||||||
|
"vite-plus": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.core { fill: #D71921; }
|
||||||
|
.ring { fill: none; stroke: #FFFFFF; stroke-width: 12; stroke-linecap: round; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- The Core: Red Center (Representing Focus/Power/AI) -->
|
||||||
|
<circle class="core" cx="50" cy="50" r="16"/>
|
||||||
|
|
||||||
|
<!-- The Network: Segmented Ring (Representing Connectivity/Security/Ecosystem) -->
|
||||||
|
<!-- Segment 1: Top Left -->
|
||||||
|
<path class="ring" d="M 28 28 A 32 32 0 0 1 50 18"/>
|
||||||
|
|
||||||
|
<!-- Segment 2: Top Right to Bottom Right -->
|
||||||
|
<path class="ring" d="M 72 28 A 32 32 0 0 1 72 72"/>
|
||||||
|
|
||||||
|
<!-- Segment 3: Bottom Left -->
|
||||||
|
<path class="ring" d="M 50 82 A 32 32 0 0 1 28 72"/>
|
||||||
|
|
||||||
|
<!-- Tech Details: Small nodes on the gaps -->
|
||||||
|
<circle fill="#F5F5F5" cx="18" cy="50" r="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.core { fill: #D71921; }
|
||||||
|
.ring { fill: none; stroke: #1A1A1A; stroke-width: 12; stroke-linecap: round; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- The Core: Red Center (Representing Focus/Power/AI) -->
|
||||||
|
<circle class="core" cx="50" cy="50" r="16"/>
|
||||||
|
|
||||||
|
<!-- The Network: Segmented Ring (Representing Connectivity/Security/Ecosystem) -->
|
||||||
|
<!-- Segment 1: Top Left -->
|
||||||
|
<path class="ring" d="M 28 28 A 32 32 0 0 1 50 18"/>
|
||||||
|
|
||||||
|
<!-- Segment 2: Top Right to Bottom Right -->
|
||||||
|
<path class="ring" d="M 72 28 A 32 32 0 0 1 72 72"/>
|
||||||
|
|
||||||
|
<!-- Segment 3: Bottom Left -->
|
||||||
|
<path class="ring" d="M 50 82 A 32 32 0 0 1 28 72"/>
|
||||||
|
|
||||||
|
<!-- Tech Details: Small nodes on the gaps -->
|
||||||
|
<circle fill="#0F0F0F" cx="18" cy="50" r="4"/>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 845 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="541" height="370" viewBox="0 0 541 370" fill="none">
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M459 95L541 95V177H532.214C491.779 177 459 144.221 459 103.786V95Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M451 95L369 95V177H377.786C418.221 177 451 144.221 451 103.786V95Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M357 177H276V95H284.679C324.621 95 357 127.779 357 168.214V177Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M184 177H266V95H257.214C216.779 95 184 127.779 184 168.214V177Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M184 0L266 0V82H257.214C216.779 82 184 49.2208 184 8.78571V0Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M356 82L274 82V0H282.786C323.221 0 356 32.7792 356 73.2143V82Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M449 82L368 82V0H376.679C416.621 0 449 32.7792 449 73.2143V82Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M459 79.8036V82H461.05V80H461V79.8036H459ZM469.25 82H465.15V80H469.25V82ZM459 75.4107H461V73.2143C461 72.5441 461.009 71.8762 461.028 71.2106L459.028 71.1553C459.01 71.8394 459 72.5257 459 73.2143V75.4107ZM459.255 67.0552L461.248 67.2212C461.36 65.8871 461.508 64.5635 461.691 63.2515L459.71 62.9743C459.522 64.3232 459.37 65.6838 459.255 67.0552ZM460.394 58.9256L462.356 59.3138C462.615 58.0026 462.91 56.7044 463.241 55.4202L461.304 54.922C460.964 56.2425 460.66 57.5774 460.394 58.9256ZM462.439 50.9736L464.345 51.5805C464.75 50.3077 465.19 49.0505 465.664 47.8099L463.796 47.0963C463.309 48.372 462.856 49.6648 462.439 50.9736ZM465.369 43.3022L467.194 44.1201C467.74 42.9025 468.319 41.7029 468.93 40.5226L467.154 39.6032C466.526 40.8167 465.93 42.0501 465.369 43.3022ZM469.144 36.0105L470.865 37.0283C471.544 35.8805 472.254 34.7533 472.994 33.6479L471.332 32.5353C470.571 33.6716 469.841 34.8304 469.144 36.0105ZM473.711 29.1876L475.308 30.3914C476.111 29.327 476.942 28.2858 477.802 27.269L476.274 25.9779C475.391 27.0231 474.536 28.0934 473.711 29.1876ZM479.012 22.9163L480.465 24.2905C481.381 23.3227 482.323 22.3805 483.291 21.4652L481.916 20.0121C480.921 20.953 479.953 21.9215 479.012 22.9163ZM484.978 17.2743L486.269 18.8017C487.286 17.9422 488.327 17.1107 489.391 16.3084L488.188 14.7113C487.093 15.5361 486.023 16.3908 484.978 17.2743ZM491.535 12.3318L492.648 13.9937C493.753 13.2537 494.88 12.5439 496.028 11.8653L495.011 10.1436C493.83 10.8413 492.672 11.5711 491.535 12.3318ZM498.603 8.15388L499.523 9.93001C500.703 9.319 501.902 8.74007 503.12 8.19435L502.302 6.36926C501.05 6.93039 499.817 7.52566 498.603 8.15388ZM506.096 4.7959L506.81 6.66425C508.051 6.19038 509.308 5.75021 510.581 5.34487L509.974 3.43919C508.665 3.856 507.372 4.30862 506.096 4.7959ZM513.922 2.30366L514.42 4.24061C515.704 3.91032 517.003 3.61493 518.314 3.35554L517.926 1.39356C516.577 1.66028 515.243 1.96401 513.922 2.30366ZM521.974 0.710419L522.252 2.69112C523.564 2.5075 524.887 2.35962 526.221 2.2485L526.055 0.255402C524.684 0.369629 523.323 0.521645 521.974 0.710419ZM473.35 82V80H477.45V82H473.35ZM481.55 82V80H485.65V82H481.55ZM489.75 82V80H493.85V82H489.75ZM497.95 82V80H502.05V82H497.95ZM506.15 82V80H510.25V82H506.15ZM514.35 82V80H518.45V82L514.35 82ZM522.55 82V80H526.65V82H522.55ZM530.75 82V80H534.85V82H530.75ZM538.95 82V80H539V79.95H541V82H538.95ZM541 75.85H539V71.75H541V75.85ZM541 67.65H539V63.55H541V67.65ZM541 59.45H539V55.35H541V59.45ZM541 51.25H539V47.15H541V51.25ZM541 43.05H539V38.95H541V43.05ZM541 34.85H539V30.75H541V34.85ZM541 26.65H539V22.55H541V26.65ZM541 18.45H539V14.35H541V18.45ZM541 10.25H539V6.15H541V10.25ZM541 2.05H539V2H538.804V0H541V2.05ZM534.411 0V2H532.214C531.544 2 530.876 2.00925 530.211 2.02763L530.155 0.0283966C530.839 0.00950623 531.526 0 532.214 0H534.411Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M275 290.196V288H277.05V290H277V290.196H275ZM285.25 288H281.15V290H285.25V288ZM275 294.589H277V296.786C277 297.456 277.009 298.124 277.028 298.789L275.028 298.845C275.01 298.161 275 297.474 275 296.786V294.589ZM275.255 302.945L277.248 302.779C277.36 304.113 277.508 305.436 277.691 306.748L275.71 307.026C275.522 305.677 275.37 304.316 275.255 302.945ZM276.394 311.074L278.356 310.686C278.615 311.997 278.91 313.296 279.241 314.58L277.304 315.078C276.964 313.757 276.66 312.423 276.394 311.074ZM278.439 319.026L280.345 318.419C280.75 319.692 281.19 320.949 281.664 322.19L279.796 322.904C279.309 321.628 278.856 320.335 278.439 319.026ZM281.369 326.698L283.194 325.88C283.74 327.098 284.319 328.297 284.93 329.477L283.154 330.397C282.526 329.183 281.93 327.95 281.369 326.698ZM285.144 333.989L286.865 332.972C287.544 334.12 288.254 335.247 288.994 336.352L287.332 337.465C286.571 336.328 285.841 335.17 285.144 333.989ZM289.711 340.812L291.308 339.609C292.111 340.673 292.942 341.714 293.802 342.731L292.274 344.022C291.391 342.977 290.536 341.907 289.711 340.812ZM295.012 347.084L296.465 345.709C297.381 346.677 298.323 347.619 299.291 348.535L297.916 349.988C296.921 349.047 295.953 348.079 295.012 347.084ZM300.978 352.726L302.269 351.198C303.286 352.058 304.327 352.889 305.391 353.692L304.188 355.289C303.093 354.464 302.023 353.609 300.978 352.726ZM307.535 357.668L308.648 356.006C309.753 356.746 310.88 357.456 312.028 358.135L311.011 359.856C309.83 359.159 308.672 358.429 307.535 357.668ZM314.603 361.846L315.523 360.07C316.703 360.681 317.902 361.26 319.12 361.806L318.302 363.631C317.05 363.07 315.817 362.474 314.603 361.846ZM322.096 365.204L322.81 363.336C324.051 363.81 325.308 364.25 326.581 364.655L325.974 366.561C324.665 366.144 323.372 365.691 322.096 365.204ZM329.922 367.696L330.42 365.759C331.704 366.09 333.003 366.385 334.314 366.644L333.926 368.606C332.577 368.34 331.243 368.036 329.922 367.696ZM337.974 369.29L338.252 367.309C339.564 367.492 340.887 367.64 342.221 367.751L342.055 369.745C340.684 369.63 339.323 369.478 337.974 369.29ZM289.35 288V290H293.45V288H289.35ZM297.55 288V290H301.65V288H297.55ZM305.75 288V290H309.85V288H305.75ZM313.95 288V290H318.05V288H313.95ZM322.15 288V290H326.25V288H322.15ZM330.35 288V290H334.45V288H330.35ZM338.55 288V290H342.65V288H338.55ZM346.75 288V290H350.85V288H346.75ZM354.95 288V290H355V290.05H357V288H354.95ZM357 294.15H355V298.25H357V294.15ZM357 302.35H355V306.45H357V302.35ZM357 310.55H355V314.65H357V310.55ZM357 318.75H355V322.85H357V318.75ZM357 326.95H355V331.05H357V326.95ZM357 335.15H355V339.25H357V335.15ZM357 343.35H355V347.45H357V343.35ZM357 351.55H355V355.65H357V351.55ZM357 359.75H355V363.85H357V359.75ZM357 367.95H355V368H354.804V370H357V367.95ZM350.411 370V368H348.214C347.544 368 346.876 367.991 346.211 367.972L346.155 369.972C346.839 369.99 347.526 370 348.214 370H350.411Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M267 288H185V370H193.786C234.221 370 267 337.221 267 296.786V288Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M173 370H92L92 288H100.679C140.621 288 173 320.779 173 361.214V370Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M0 370H82L82 288H73.2143C32.7792 288 0 320.779 0 361.214L0 370Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M0 195.196L0 193H2.05001L2.05001 195H2V195.196H0ZM10.25 193H6.15001V195H10.25V193ZM0 199.589H2L2 201.786C2 202.456 2.00925 203.124 2.02763 203.789L0.0283928 203.845C0.00950564 203.161 0 202.474 0 201.786L0 199.589ZM0.255399 207.945L2.2485 207.779C2.35962 209.113 2.5075 210.436 2.69112 211.748L0.71042 212.026C0.521646 210.677 0.369624 209.316 0.255399 207.945ZM1.39356 216.074L3.35554 215.686C3.61493 216.997 3.91032 218.296 4.24061 219.58L2.30366 220.078C1.96402 218.757 1.66028 217.423 1.39356 216.074ZM3.43919 224.026L5.34488 223.419C5.75022 224.692 6.19038 225.949 6.66425 227.19L4.7959 227.904C4.30863 226.628 3.856 225.335 3.43919 224.026ZM6.36926 231.698L8.19436 230.88C8.74007 232.098 9.319 233.297 9.93002 234.477L8.15388 235.397C7.52566 234.183 6.93039 232.95 6.36926 231.698ZM10.1437 238.989L11.8653 237.972C12.5439 239.12 13.2537 240.247 13.9938 241.352L12.3318 242.465C11.5711 241.328 10.8413 240.17 10.1437 238.989ZM14.7113 245.812L16.3085 244.609C17.1107 245.673 17.9422 246.714 18.8017 247.731L17.2743 249.022C16.3908 247.977 15.5361 246.907 14.7113 245.812ZM20.0121 252.084L21.4652 250.709C22.3805 251.677 23.3227 252.619 24.2905 253.535L22.9163 254.988C21.9215 254.047 20.953 253.079 20.0121 252.084ZM25.9779 257.726L27.269 256.198C28.2858 257.058 29.327 257.889 30.3914 258.692L29.1876 260.289C28.0934 259.464 27.0231 258.609 25.9779 257.726ZM32.5353 262.668L33.6479 261.006C34.7533 261.746 35.8805 262.456 37.0283 263.135L36.0106 264.856C34.8305 264.159 33.6716 263.429 32.5353 262.668ZM39.6032 266.846L40.5226 265.07C41.7029 265.681 42.9025 266.26 44.1201 266.806L43.3022 268.631C42.0502 268.07 40.8167 267.474 39.6032 266.846ZM47.0963 270.204L47.8099 268.336C49.0505 268.81 50.3078 269.25 51.5805 269.655L50.9736 271.561C49.6648 271.144 48.372 270.691 47.0963 270.204ZM54.922 272.696L55.4202 270.759C56.7044 271.09 58.0026 271.385 59.3138 271.644L58.9256 273.606C57.5774 273.34 56.2425 273.036 54.922 272.696ZM62.9743 274.29L63.2515 272.309C64.5635 272.492 65.8871 272.64 67.2212 272.751L67.0552 274.745C65.6838 274.63 64.3232 274.478 62.9743 274.29ZM14.35 193V195H18.45V193H14.35ZM22.55 193V195H26.65V193H22.55ZM30.75 193V195H34.85V193H30.75ZM38.95 193V195H43.05V193H38.95ZM47.15 193V195H51.25V193H47.15ZM55.35 193V195H59.45V193H55.35ZM63.55 193V195H67.65V193H63.55ZM71.75 193V195H75.85V193H71.75ZM79.95 193V195H80V195.05H82V193H79.95ZM82 199.15H80V203.25H82V199.15ZM82 207.35H80V211.45H82V207.35ZM82 215.55H80V219.65H82V215.55ZM82 223.75H80V227.85H82V223.75ZM82 231.95H80V236.05H82V231.95ZM82 240.15H80V244.25H82V240.15ZM82 248.35H80L80 252.45H82L82 248.35ZM82 256.55H80V260.65H82V256.55ZM82 264.75H80V268.85H82V264.75ZM82 272.95H80V273H79.8036V275H82V272.95ZM75.4107 275V273H73.2143C72.5441 273 71.8762 272.991 71.2105 272.972L71.1553 274.972C71.8393 274.99 72.5257 275 73.2143 275H75.4107Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M172 275H90L90 193H98.7857C139.221 193 172 225.779 172 266.214V275Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M539 367.804V370H536.95V368H537V367.804H539ZM528.75 370H532.85V368H528.75V370ZM539 363.411H537V361.214C537 360.544 536.991 359.876 536.972 359.211L538.972 359.155C538.99 359.839 539 360.526 539 361.214V363.411ZM538.745 355.055L536.752 355.221C536.64 353.887 536.492 352.564 536.309 351.252L538.29 350.974C538.478 352.323 538.63 353.684 538.745 355.055ZM537.606 346.926L535.644 347.314C535.385 346.003 535.09 344.704 534.759 343.42L536.696 342.922C537.036 344.243 537.34 345.577 537.606 346.926ZM535.561 338.974L533.655 339.581C533.25 338.308 532.81 337.051 532.336 335.81L534.204 335.096C534.691 336.372 535.144 337.665 535.561 338.974ZM532.631 331.302L530.806 332.12C530.26 330.902 529.681 329.703 529.07 328.523L530.846 327.603C531.474 328.817 532.07 330.05 532.631 331.302ZM528.856 324.011L527.135 325.028C526.456 323.88 525.746 322.753 525.006 321.648L526.668 320.535C527.429 321.672 528.159 322.83 528.856 324.011ZM524.289 317.188L522.692 318.391C521.889 317.327 521.058 316.286 520.198 315.269L521.726 313.978C522.609 315.023 523.464 316.093 524.289 317.188ZM518.988 310.916L517.535 312.291C516.619 311.323 515.677 310.381 514.709 309.465L516.084 308.012C517.079 308.953 518.047 309.921 518.988 310.916ZM513.022 305.274L511.731 306.802C510.714 305.942 509.673 305.111 508.609 304.308L509.812 302.711C510.907 303.536 511.977 304.391 513.022 305.274ZM506.465 300.332L505.352 301.994C504.247 301.254 503.12 300.544 501.972 299.865L502.989 298.144C504.17 298.841 505.328 299.571 506.465 300.332ZM499.397 296.154L498.477 297.93C497.297 297.319 496.098 296.74 494.88 296.194L495.698 294.369C496.95 294.93 498.183 295.526 499.397 296.154ZM491.904 292.796L491.19 294.664C489.949 294.19 488.692 293.75 487.419 293.345L488.026 291.439C489.335 291.856 490.628 292.309 491.904 292.796ZM484.078 290.304L483.58 292.241C482.296 291.91 480.997 291.615 479.686 291.356L480.074 289.394C481.423 289.66 482.757 289.964 484.078 290.304ZM476.026 288.71L475.748 290.691C474.436 290.507 473.113 290.36 471.779 290.248L471.945 288.255C473.316 288.37 474.677 288.522 476.026 288.71ZM524.65 370V368H520.55V370H524.65ZM516.45 370V368H512.35V370H516.45ZM508.25 370V368H504.15V370H508.25ZM500.05 370V368H495.95V370H500.05ZM491.85 370V368H487.75V370H491.85ZM483.65 370V368H479.55V370H483.65ZM475.45 370V368H471.35V370H475.45ZM467.25 370V368H463.15V370H467.25ZM459.05 370V368H459V367.95H457V370H459.05ZM457 363.85H459V359.75H457V363.85ZM457 355.65H459V351.55H457V355.65ZM457 347.45H459V343.35H457V347.45ZM457 339.25H459V335.15H457V339.25ZM457 331.05H459V326.95H457V331.05ZM457 322.85H459V318.75H457V322.85ZM457 314.65H459V310.55H457V314.65ZM457 306.45H459V302.35H457V306.45ZM457 298.25H459V294.15H457V298.25ZM457 290.05H459V290H459.196V288H457V290.05ZM463.589 288V290H465.786C466.456 290 467.124 290.009 467.789 290.028L467.845 288.028C467.161 288.01 466.474 288 465.786 288H463.589Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M366 370H447V288H438.321C398.379 288 366 320.779 366 361.214V370Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M366 193H447V275H438.321C398.379 275 366 242.221 366 201.786V193Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M537 275H456V193H464.679C504.621 193 537 225.779 537 266.214V275Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M265 272.804V275H262.975V273H263V272.804H265ZM254.875 275H258.925V273H254.875V275ZM265 268.411H263V266.214C263 265.548 262.991 264.884 262.973 264.222L264.972 264.168C264.991 264.848 265 265.53 265 266.214V268.411ZM264.751 260.092L262.757 260.255C262.649 258.929 262.505 257.612 262.325 256.308L264.307 256.035C264.491 257.376 264.639 258.729 264.751 260.092ZM263.64 252.009L261.676 252.39C261.423 251.086 261.134 249.794 260.812 248.516L262.751 248.026C263.083 249.34 263.379 250.668 263.64 252.009ZM261.641 244.097L259.733 244.694C259.336 243.426 258.906 242.174 258.442 240.938L260.315 240.235C260.791 241.506 261.234 242.794 261.641 244.097ZM258.776 236.454L256.945 237.26C256.411 236.045 255.844 234.849 255.245 233.671L257.028 232.765C257.643 233.976 258.226 235.206 258.776 236.454ZM255.078 229.179L253.348 230.183C252.683 229.036 251.987 227.91 251.261 226.806L252.932 225.707C253.678 226.843 254.394 228 255.078 229.179ZM250.596 222.36L248.988 223.55C248.2 222.484 247.383 221.442 246.538 220.424L248.077 219.147C248.945 220.193 249.786 221.265 250.596 222.36ZM245.384 216.079L243.918 217.44C243.017 216.47 242.089 215.525 241.136 214.607L242.524 213.166C243.504 214.11 244.457 215.082 245.384 216.079ZM239.506 210.418L238.201 211.933C237.198 211.07 236.171 210.234 235.121 209.428L236.339 207.842C237.419 208.671 238.475 209.53 239.506 210.418ZM233.032 205.448L231.905 207.1C230.813 206.355 229.699 205.641 228.564 204.958L229.596 203.244C230.763 203.947 231.909 204.682 233.032 205.448ZM226.041 201.239L225.108 203.008C223.94 202.392 222.752 201.808 221.547 201.258L222.378 199.438C223.618 200.004 224.839 200.605 226.041 201.239ZM218.617 197.85L217.892 199.714C216.662 199.235 215.416 198.791 214.154 198.381L214.771 196.479C216.07 196.9 217.352 197.358 218.617 197.85ZM210.853 195.331L210.346 197.266C209.071 196.932 207.783 196.633 206.482 196.371L206.877 194.41C208.216 194.68 209.541 194.988 210.853 195.331ZM202.855 193.719L202.573 195.699C201.27 195.513 199.956 195.364 198.631 195.251L198.8 193.259C200.163 193.374 201.515 193.528 202.855 193.719ZM250.825 275V273H246.775V275H250.825ZM242.725 275V273H238.675V275H242.725ZM234.625 275V273H230.575V275H234.625ZM226.525 275V273H222.475V275H226.525ZM218.425 275V273H214.375V275H218.425ZM210.325 275V273H206.275V275H210.325ZM202.225 275V273H198.175V275H202.225ZM194.125 275V273H190.075V275H194.125ZM186.025 275V273H186V272.95H184V275H186.025ZM184 268.85H186V264.75H184V268.85ZM184 260.65H186V256.55H184V260.65ZM184 252.45H186V248.35H184V252.45ZM184 244.25H186V240.15H184V244.25ZM184 236.05H186V231.95H184V236.05ZM184 227.85H186V223.75H184V227.85ZM184 219.65H186V215.55H184V219.65ZM184 211.45H186V207.35H184V211.45ZM184 203.25H186V199.15H184V203.25ZM184 195.05H186V195H186.17V193H184V195.05ZM190.509 193V195H192.679C193.344 195 194.008 195.009 194.669 195.028L194.725 193.029C194.045 193.01 193.363 193 192.679 193H190.509Z" fill="#A3A3A3"/>
|
||||||
|
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M275 275H357V193H348.214C307.779 193 275 225.779 275 266.214V275Z" fill="#A3A3A3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"filesScanned": 120,
|
||||||
|
"elementsParsed": 10219,
|
||||||
|
"vstackUses": 1425,
|
||||||
|
"hstackUses": 853
|
||||||
|
},
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Layout idiom census — exact VStack/HStack/Card/Grid/Section configs (≥ 3 uses, ≥ 2 files)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap=1)",
|
||||||
|
"count": 464,
|
||||||
|
"files": 34,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx … (+29 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:194"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap=0)",
|
||||||
|
"count": 360,
|
||||||
|
"files": 34,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/components/MemberTable.tsx … (+29 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:174"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "HStack(gap=2,vAlign=center)",
|
||||||
|
"count": 204,
|
||||||
|
"files": 26,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/tasks/TaskKanbanBoard.tsx, src/pages/AgentDetails.tsx … (+21 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:166"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Card(padding=4)",
|
||||||
|
"count": 160,
|
||||||
|
"files": 16,
|
||||||
|
"filesList": "src/features/dispatch/IntegrationCard.tsx, src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx, src/pages/ConnectorDetails.tsx, src/pages/Connectors.tsx … (+11 more)",
|
||||||
|
"example": "src/features/dispatch/IntegrationCard.tsx:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "StackItem(size=fill)",
|
||||||
|
"count": 150,
|
||||||
|
"files": 20,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/playbooks/PlaybookFlow.tsx … (+15 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:173"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "HStack(gap=3,vAlign=center)",
|
||||||
|
"count": 138,
|
||||||
|
"files": 33,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/components/MemberTable.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx … (+28 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:370"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap=3)",
|
||||||
|
"count": 128,
|
||||||
|
"files": 30,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/pages/OrganizationCreatePage.tsx, src/features/playbooks/PlaybookDiagram.tsx, src/features/playbooks/PlaybookFlow.tsx … (+25 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:193"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap=4)",
|
||||||
|
"count": 114,
|
||||||
|
"files": 19,
|
||||||
|
"filesList": "src/features/auth/pages/LoginPage.tsx, src/features/auth/pages/SignupPage.tsx, src/features/organizations/components/OrganizationForm.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPlugins.tsx … (+14 more)",
|
||||||
|
"example": "src/features/auth/pages/LoginPage.tsx:115"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap=2)",
|
||||||
|
"count": 96,
|
||||||
|
"files": 20,
|
||||||
|
"filesList": "src/features/agent-backends/AcpLaunchInstructions.tsx, src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/dispatch/IntegrationCard.tsx … (+15 more)",
|
||||||
|
"example": "src/features/agent-backends/AcpLaunchInstructions.tsx:31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Grid(columns=expr,gap=4)",
|
||||||
|
"count": 93,
|
||||||
|
"files": 22,
|
||||||
|
"filesList": "src/features/auth/pages/ProfileSettingsPage.tsx, src/features/auth/pages/SignupPage.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx … (+17 more)",
|
||||||
|
"example": "src/features/auth/pages/ProfileSettingsPage.tsx:129"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 81
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Repeated JSX structure fingerprints (≥ 3 occurrences, ≥ 2 files)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"signature": "Text(color:s,type:s)[#t1]",
|
||||||
|
"count": 1255,
|
||||||
|
"files": 47,
|
||||||
|
"filesList": "src/features/agent-backends/AcpLaunchInstructions.tsx, src/features/artifacts/ArtifactViewer.tsx, src/features/auth/components/AuthLayout.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/conversations/WorkspaceToolsPanel.tsx … (+42 more)",
|
||||||
|
"example": "src/features/agent-backends/AcpLaunchInstructions.tsx:24"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Heading(level:s)[#t1]",
|
||||||
|
"count": 837,
|
||||||
|
"files": 42,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx … (+37 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:156"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Icon(color:s,icon:s,size:s)[]",
|
||||||
|
"count": 347,
|
||||||
|
"files": 40,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/dispatch/ProjectDispatch.tsx … (+35 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:126"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Text(type:s,weight:s)[#t1]",
|
||||||
|
"count": 314,
|
||||||
|
"files": 31,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/pages/ProfileSettingsPage.tsx, src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx … (+26 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:175"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Badge(label:s,variant:s)[]",
|
||||||
|
"count": 256,
|
||||||
|
"files": 37,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/git-providers/GitProviderSelector.tsx, src/features/organizations/components/InvitationTable.tsx … (+32 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:387"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap:s)[Heading(level:s)[#t1];Text(color:s,type:s)[#t1]]",
|
||||||
|
"count": 218,
|
||||||
|
"files": 28,
|
||||||
|
"filesList": "src/features/auth/pages/ProfileSettingsPage.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/OrganizationSettingsContainer.tsx, src/pages/AgentDetails.tsx, src/pages/AgentPluginDetails.tsx … (+23 more)",
|
||||||
|
"example": "src/features/auth/pages/ProfileSettingsPage.tsx:93"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Text(type:s)[#t1]",
|
||||||
|
"count": 182,
|
||||||
|
"files": 25,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/dispatch/IntegrationCard.tsx, src/features/organizations/pages/OrganizationCreatePage.tsx, src/features/playbooks/PlaybookFlow.tsx … (+20 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:267"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Icon(icon:s,size:s)[]",
|
||||||
|
"count": 170,
|
||||||
|
"files": 47,
|
||||||
|
"filesList": "src/features/artifacts/ArtifactViewer.tsx, src/features/auth/containers/UserProfileMenu.tsx, src/features/auth/pages/LoginPage.tsx, src/features/auth/pages/SignupPage.tsx, src/features/connectors/EnvironmentLabels.tsx … (+42 more)",
|
||||||
|
"example": "src/features/artifacts/ArtifactViewer.tsx:244"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "Button(label:s,onClick:s,variant:s)[]",
|
||||||
|
"count": 149,
|
||||||
|
"files": 28,
|
||||||
|
"filesList": "src/features/conversations/WorkspaceToolsPanel.tsx, src/features/dispatch/ProjectDispatch.tsx, src/features/organizations/containers/InvitationContainer.tsx, src/features/organizations/containers/OrganizationInvitationsContainer.tsx, src/features/organizations/containers/OrganizationPeopleContainer.tsx … (+23 more)",
|
||||||
|
"example": "src/features/conversations/WorkspaceToolsPanel.tsx:224"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "VStack(gap:s)[Text(color:s,type:s)[#t1];Heading(level:s)[#t1];Text(color:s,type:s)[#t1]]",
|
||||||
|
"count": 136,
|
||||||
|
"files": 13,
|
||||||
|
"filesList": "src/pages/AgentPluginDetails.tsx, src/pages/AgentPlugins.tsx, src/pages/ConnectorDetails.tsx, src/pages/Connectors.tsx, src/pages/GitProviderDetails.tsx … (+8 more)",
|
||||||
|
"example": "src/pages/AgentPluginDetails.tsx:235"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Known hand-rolled idioms — extraction candidates",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"idiom": "Label+description pair (VStack gap=0: Text + supporting Text)",
|
||||||
|
"key": "labelPair",
|
||||||
|
"count": 64,
|
||||||
|
"files": 18,
|
||||||
|
"examples": [
|
||||||
|
"src/features/artifacts/ArtifactViewer.tsx:174",
|
||||||
|
"src/features/auth/containers/UserProfileMenu.tsx:88",
|
||||||
|
"src/features/auth/pages/ProfileSettingsPage.tsx:108"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "Section header (VStack gap=0: Heading + supporting Text)",
|
||||||
|
"key": "sectionHeader",
|
||||||
|
"count": 163,
|
||||||
|
"files": 25,
|
||||||
|
"examples": [
|
||||||
|
"src/features/organizations/containers/InvitationContainer.tsx:184",
|
||||||
|
"src/features/playbooks/PlaybookDiagram.tsx:105",
|
||||||
|
"src/features/playbooks/PlaybookFlow.tsx:35"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "Stat card (Card padding>=3 > VStack gap=1: 2 supporting Texts + Heading)",
|
||||||
|
"key": "statCard",
|
||||||
|
"count": 138,
|
||||||
|
"files": 13,
|
||||||
|
"examples": [
|
||||||
|
"src/pages/AgentPluginDetails.tsx:234",
|
||||||
|
"src/pages/AgentPlugins.tsx:240",
|
||||||
|
"src/pages/ConnectorDetails.tsx:175"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "Icon + text row (HStack vAlign=center: Icon + VStack)",
|
||||||
|
"key": "iconTextRow",
|
||||||
|
"count": 54,
|
||||||
|
"files": 14,
|
||||||
|
"examples": [
|
||||||
|
"src/pages/AgentPluginDetails.tsx:123",
|
||||||
|
"src/pages/AgentPlugins.tsx:124",
|
||||||
|
"src/pages/GitProviderDetails.tsx:111"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "Form action row (HStack gap=2: 2+ Buttons)",
|
||||||
|
"key": "formActions",
|
||||||
|
"count": 43,
|
||||||
|
"files": 13,
|
||||||
|
"examples": [
|
||||||
|
"src/features/artifacts/ArtifactViewer.tsx:216",
|
||||||
|
"src/pages/AgentDetails.tsx:806",
|
||||||
|
"src/pages/Agents.tsx:783"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "Wizard progress (HStack wrap=wrap of Badge+Text steps)",
|
||||||
|
"key": "wizardProgress",
|
||||||
|
"count": 0,
|
||||||
|
"files": 0,
|
||||||
|
"examples": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Most similar page pairs (Jaccard over structure fingerprints)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"pair": "src/pages/GitProviders.tsx ↔ src/pages/Integrations.tsx",
|
||||||
|
"jaccard": "0.76",
|
||||||
|
"shared": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/NewIntegration.tsx ↔ src/pages/NewIssueProvider.tsx",
|
||||||
|
"jaccard": "0.58",
|
||||||
|
"shared": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/NewGitProvider.tsx ↔ src/pages/NewIssueProvider.tsx",
|
||||||
|
"jaccard": "0.57",
|
||||||
|
"shared": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/GitProviderDetails.tsx ↔ src/pages/IntegrationDetails.tsx",
|
||||||
|
"jaccard": "0.54",
|
||||||
|
"shared": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/NewGitProvider.tsx ↔ src/pages/NewIntegration.tsx",
|
||||||
|
"jaccard": "0.54",
|
||||||
|
"shared": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/Integrations.tsx ↔ src/pages/IssueProviders.tsx",
|
||||||
|
"jaccard": "0.47",
|
||||||
|
"shared": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/Integrations.tsx ↔ src/pages/Playbooks.tsx",
|
||||||
|
"jaccard": "0.47",
|
||||||
|
"shared": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/GitProviders.tsx ↔ src/pages/IssueProviders.tsx",
|
||||||
|
"jaccard": "0.46",
|
||||||
|
"shared": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/features/organizations/containers/OrganizationInvitationsContainer.tsx ↔ src/features/organizations/containers/OrganizationPeopleContainer.tsx",
|
||||||
|
"jaccard": "0.43",
|
||||||
|
"shared": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pair": "src/pages/GitProviders.tsx ↔ src/pages/Playbooks.tsx",
|
||||||
|
"jaccard": "0.42",
|
||||||
|
"shared": 17
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Most co-imported @astryxdesign/core modules (signals composite components to extract)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"modules": "/Layout|@astryxdesign/core/Text",
|
||||||
|
"count": 46,
|
||||||
|
"files": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Icon|@astryxdesign/core/Layout",
|
||||||
|
"count": 45,
|
||||||
|
"files": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Button|@astryxdesign/core/Layout",
|
||||||
|
"count": 43,
|
||||||
|
"files": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Icon|@astryxdesign/core/Text",
|
||||||
|
"count": 42,
|
||||||
|
"files": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Button|@astryxdesign/core/Text",
|
||||||
|
"count": 39,
|
||||||
|
"files": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Button|@astryxdesign/core/Icon",
|
||||||
|
"count": 38,
|
||||||
|
"files": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Badge|@astryxdesign/core/Icon",
|
||||||
|
"count": 33,
|
||||||
|
"files": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Badge|@astryxdesign/core/Layout",
|
||||||
|
"count": 33,
|
||||||
|
"files": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Badge|@astryxdesign/core/Text",
|
||||||
|
"count": 32,
|
||||||
|
"files": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modules": "/Card|@astryxdesign/core/Layout",
|
||||||
|
"count": 30,
|
||||||
|
"files": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 211
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Component Extraction Report — Attendance web-app
|
||||||
|
|
||||||
|
Generated by `scripts/find-duplicates.mjs` (computed duplication scan) + 5 parallel
|
||||||
|
code-reading agents (file-level evidence). Web app: React 19 + `@astryxdesign/core`
|
||||||
|
design system + StyleX. Raw machine-readable data: `scripts/duplication-report.json`.
|
||||||
|
|
||||||
|
## Headline numbers
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------------------------------------------- | ---------------------------------------- |
|
||||||
|
| Files / JSX elements scanned | 120 / 10,219 |
|
||||||
|
| `VStack` uses | 1,425 (incl. 464× `gap=1`, 360× `gap=0`) |
|
||||||
|
| `HStack` uses | 853 (incl. 204× `gap=2 vAlign=center`) |
|
||||||
|
| Files importing `@astryxdesign/core/Layout` | 68 |
|
||||||
|
|
||||||
|
Top file pairs by structural similarity (Jaccard):
|
||||||
|
`GitProviders ↔ Integrations` **76%**, `NewIntegration ↔ NewIssueProvider` **58%**,
|
||||||
|
`NewGitProvider ↔ NewIssueProvider` **57%**, `GitProviderDetails ↔ IntegrationDetails`
|
||||||
|
**54%**, `NewGitProvider ↔ NewIntegration` **54%**.
|
||||||
|
|
||||||
|
## Tier 1 — byte-identical patterns, extract first
|
||||||
|
|
||||||
|
| Candidate | Occurrences | Evidence | API sketch |
|
||||||
|
| -------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| **`StatCard` / `StatCardGrid`** | ~22 list + ~30 detail cards across 13 files | `GitProviders.tsx:176-185`, `LLMProviderDetails.tsx:274-283`, `AgentPlugins.tsx:240-249`, `Usage.tsx:612-675` (icon/trend variant) | `{ label, value, hint?, icon?, footnote? }` + `StatCardGrid({ cards, columns })` |
|
||||||
|
| **`SectionHeader`** | 163 CLI hits / 25 files | `GitProviderDetails.tsx:205-210`, `IntegrationDetails.tsx:259-264`, `AgentDetails.tsx:423-428` | `{ title, description?, endAction?, level? }` |
|
||||||
|
| **`WizardProgress`** | 3 byte-identical + 2 variants | `NewGitProvider.tsx:28-53`, `NewIssueProvider.tsx:47-72`, `NewIntegration.tsx:28-53` (only `STEP_LABELS` differs); sidebar variant `NewLLMProvider.tsx:134-158` | `{ steps: string[], current: number, variant? }` — slot into `<PageHero actions={…}>` |
|
||||||
|
| **`StatusCell`** | 7 list + 3 detail + dispatch | `GitProviders.tsx:95-105`, `Integrations.tsx:90-105`, `ChatSessions.tsx:114-122` | `{ variant, label, isPulsing?, detail? }` (wraps `StatusDot` + `Text`) |
|
||||||
|
| **`LeadingCell` / `EntityRow`** | ~9 list + 7 feature sites | `GitProviders.tsx:66-76`, `PlaybookDiagram.tsx:102-112`, `ArtifactViewer.tsx:166-187` | `{ leading?, title, subtitle?, meta?/trailing? }` |
|
||||||
|
| **`FormFooter` / `FormActions`** | 7 instances / 6 files | `GitProviderDetails.tsx:296-308`, `LLMProviderDetails.tsx:425-433`, `AgentDetails.tsx:802-815` (Reset/dirty variant) | `{ saved?, primaryLabel, onPrimary, secondaryLabel?, onSecondary?, isDirty? }` |
|
||||||
|
| **`BackButton`** | 12+ sites | `NewGitProvider.tsx:104-109`, `GitProviderDetails.tsx:59-64`, `NewPlaybook.tsx:757-764` | `{ label, href, iconOnly? }` (ghost + `IconArrowLeft` + navigate) |
|
||||||
|
|
||||||
|
## Tier 2 — near-identical composites (bigger wins)
|
||||||
|
|
||||||
|
## Tier 2 — near-identical composites (bigger wins)
|
||||||
|
|
||||||
|
> ⚠️ The codebase is being actively rewritten while this report was generated (e.g. `DetailHero` below landed mid-analysis). Line references are as-of-scan-time and may be slightly stale.
|
||||||
|
|
||||||
|
| Candidate | Occurrences | Evidence |
|
||||||
|
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **`DetailHero`** — ✅ **already exists** in `src/layouts/DetailHero.tsx`, already adopted by 8 detail pages (`AgentDetails`, `AgentPluginDetails`, `ConnectorDetails`, `GitProviderDetails`, `IntegrationDetails`, `IssueProviderDetails`, `LLMProviderDetails`, `PlaybookDetails`). **Remaining adopters:** `TaskDetails` (LayoutHeader variant), `DispatchIntegrations`, `Projects` (ProjectDetailsPage), `ArtifactViewer` | ~11 total | API `{ breadcrumbs?, leading?, status?, title, metadata?, description?, actions? }` — exactly the shape recommended by the infra analysis; replaces the hand-rolled hero block |
|
||||||
|
| **`WizardPageShell`** | 3 full + 2 partial | `NewGitProvider.tsx:101-350`, `NewIssueProvider.tsx:139-393`, `NewIntegration.tsx:92-320` — back button + hero + step Card + footer |
|
||||||
|
| **`ProviderPicker`** | 5 | `NewGitProvider.tsx:130-155`, `NewLLMProvider.tsx:173-191` — `SelectableCard`/`ClickableCard` grid over `Object.keys(definitions)` |
|
||||||
|
| **`ReviewSummary` + `SummaryGrid`** | 3 | `NewGitProvider.tsx:272-319`, `NewIntegration.tsx:241-288` — muted Section + icon/name + label-value grid |
|
||||||
|
| **`TableSection`** | 11 | `GitProviders.tsx:211-230`, `Usage.tsx:828-851` — section header + `Card padding={0}` + `Table` (fixed 7-prop set `density dividers verticalAlign textOverflow hasHover`) |
|
||||||
|
| **`FilterToolbar`** | 6 | `Playbooks.tsx:196-221`, `Projects.tsx:415-450`, `ChatSessions.tsx:190-204` — hidden-label search + `SegmentedControl`/`Selector` + count |
|
||||||
|
| **`DeleteResourceDialog` + `DangerZone`** | 3 detail + 4 org containers | `GitProviderDetails.tsx:314-347`, `OrganizationSettingsContainer.tsx:218-240` |
|
||||||
|
| **`EmptyStateCard`** | 5 | `ProjectDispatch.tsx:79-92`, `OrganizationPeopleContainer.tsx:186-200` |
|
||||||
|
| **`IconActionMenu`** | 4 | tasks + org tables + conversations (`DropdownMenu` ghost icon button) |
|
||||||
|
| **`ValidatedField`** (auth) | ~15 fields / 5 forms | `LoginPage.tsx:116-171`, `SignupPage.tsx:164-355`, `ProfileSettingsPage.tsx:130-223`, `OrganizationForm`, `InviteMemberDialog` |
|
||||||
|
|
||||||
|
## Tier 3 — hooks & data/logic extraction
|
||||||
|
|
||||||
|
| Candidate | Occurrences | Evidence |
|
||||||
|
| ---------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **`useRouteSection`** | 7 | `AgentDetails.tsx:820-830`, `GitProviderDetails.tsx:41-51`, `LLMProviderDetails.tsx:225-228` — normalizes `:section` param + `goToSection` (also fixes the `overview → ""` suffix inconsistency) |
|
||||||
|
| **`useSeededList`** | 4 | `GitProviders.tsx:30-33`, `Integrations.tsx:28-33` — `location.state?.createdX` seeding |
|
||||||
|
| **`useFilteredList`** | 6 | AgentPlugins, Playbooks, Projects, Agents, ChatSessions, Tasks |
|
||||||
|
| **`useDetailEntity`** | 5 | `GitProviderDetails.tsx:46`, `IntegrationDetails.tsx:91` — state-entity-or-lookup + patch + didSave |
|
||||||
|
| **`createKindRegistry` / `createStatusMap` / `createSeedStore`** | 8 data modules | `git-provider-data.ts:17-50`, `connector-data.ts:79-89`, `dispatch-data.ts:195-241` — collapse the per-feature `Record<Kind, Definition>` + `getX(id)` + label/variant map trio; UI status maps (`TaskKanbanBoard.tsx:93-114`, `ArtifactViewer.tsx:64-69`) fold in |
|
||||||
|
| **Shared contracts package (`packages/`)** | web-app ↔ worker | `auth-client.ts` vs `auth-options.ts`: user fields, password min 8, username 3-30 regex, auth base URL, `APP_NAME`, invitation roles duplicated across apps — `pnpm-workspace.yaml` currently only includes `apps/*` |
|
||||||
|
|
||||||
|
## Tier 4 — consistency fixes to fold into the rewrite
|
||||||
|
|
||||||
|
- **`PageHero` bypasses** (inconsistent hero styling: `gap={1}` + plain `Heading level={1}` vs `PageHero`'s `gap={2}` + `display-2`): `Agents.tsx:582-595`, `LLMProviders.tsx:28-41`, `NewLLMProvider.tsx:111-121`, `OrganizationPeopleContainer.tsx:138-166`.
|
||||||
|
- **`HeroTabs` bypass**: `Projects.tsx:1497-1532` re-implements it inline with raw `Toolbar`+`TabList` — drop-in swap.
|
||||||
|
- **`FormLayout` unused**: the design system ships it, but every form hand-rolls field sets in `VStack`/`Grid` — worth centralizing with `ValidatedField`.
|
||||||
|
- Feature-local duplicates (only extract within the feature): org `Skeleton` blocks, org confirm `AlertDialog` flows (`useConfirmAction`), playbooks `conditionLabels`/`edgeColors` maps (`PlaybookDiagram.tsx:74-86` vs `PlaybookFlow.tsx:10-22`), `WorkspaceToolsPanel`'s three terminal/browser/vnc panels.
|
||||||
|
|
||||||
|
## Deliberately NOT worth extracting
|
||||||
|
|
||||||
|
- `LLMProviders` item-list (deliberate non-table presentation), `TasksView` (already shared), Usage charts (analytics-specific), `NewPlaybook` diagram inspector (single consumer), kanban DnD engine and dagre/ReactFlow layouts (single-use), per-feature info `Banner` copy, `goToSection` standalone (absorbed by hook), per-feature status label _maps_ (keep local; extract only the pill rendering).
|
||||||
|
|
||||||
|
## Suggested landing order (if the team proceeds)
|
||||||
|
|
||||||
|
1. Create `apps/web-app/src/components/` (currently none exists — everything lives in `features/`).
|
||||||
|
2. Land `TitleBlock`/`SectionHeader` + `EntityRow` first (together ~24 call sites across 8 features).
|
||||||
|
3. Land `StatCard`/`StatCardGrid`, `StatusCell`, `FormFooter`, `BackButton`.
|
||||||
|
4. Then composites: `useRouteSection` (routing hook for the 7 tabbed detail pages — `DetailHero` is done, the routing layer is the remaining shared piece), `WizardPageShell` + `WizardProgress` + `ProviderPicker`, `TableSection` + `FilterToolbar`.
|
||||||
|
5. Then data layer: kind-registry/status-map factories, `useSeededList`/`useFilteredList`, and the `packages/` contracts member for web-app ↔ worker auth constants.
|
||||||
|
|
||||||
|
## Rerunning the scanner
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/web-app
|
||||||
|
node scripts/find-duplicates.mjs # terminal report
|
||||||
|
node scripts/find-duplicates.mjs --json # machine-readable
|
||||||
|
node scripts/find-duplicates.mjs --limit 20 --min-count 5
|
||||||
|
```
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* find-duplicates — component duplication scanner for the web-app source.
|
||||||
|
*
|
||||||
|
* Parses every TSX file under `src` with the TypeScript compiler API and
|
||||||
|
* computes four things:
|
||||||
|
*
|
||||||
|
* 1. Layout idiom census — exact VStack/HStack/Card/Section/Grid prop
|
||||||
|
* configurations repeated across the codebase (e.g. `VStack(gap=0)`).
|
||||||
|
* 2. Structural fingerprints — normalized JSX subtree shapes (text and
|
||||||
|
* non-layout prop values stripped) that recur in ≥ N places. These are
|
||||||
|
* the raw material for extracting shared components.
|
||||||
|
* 3. Known idioms — hand-tuned recognizers for patterns that keep showing
|
||||||
|
* up by hand: label+description pairs, stat cards, section headers,
|
||||||
|
* icon+text rows, form action footers, wizard progress steps.
|
||||||
|
* 4. File similarity — pairwise Jaccard over each file's structural
|
||||||
|
* fingerprints, surfacing pages that are near-copies of each other.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/find-duplicates.mjs # run from apps/web-app
|
||||||
|
* node scripts/find-duplicates.mjs --root src --min-count 3
|
||||||
|
* node scripts/find-duplicates.mjs --json > report.json
|
||||||
|
* node scripts/find-duplicates.mjs --limit 8 # rows per section
|
||||||
|
*
|
||||||
|
* Zero runtime dependencies: only `typescript` (already a devDependency).
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const ts = require("typescript");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI args
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flag = (name, fallback) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i === -1 ? fallback : args[i + 1];
|
||||||
|
};
|
||||||
|
const has = (name) => args.includes(name);
|
||||||
|
|
||||||
|
const ROOT = flag("--root", "src");
|
||||||
|
const MIN_COUNT = Number(flag("--min-count", "3"));
|
||||||
|
const LIMIT = Number(flag("--limit", "10"));
|
||||||
|
const AS_JSON = has("--json");
|
||||||
|
|
||||||
|
const LAYOUT_TAGS = new Set([
|
||||||
|
"VStack",
|
||||||
|
"HStack",
|
||||||
|
"StackItem",
|
||||||
|
"Section",
|
||||||
|
"Card",
|
||||||
|
"Grid",
|
||||||
|
"Center",
|
||||||
|
"Layout",
|
||||||
|
"LayoutHeader",
|
||||||
|
"LayoutContent",
|
||||||
|
"LayoutFooter",
|
||||||
|
"LayoutPanel",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Source collection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SRC_DIR = resolve(process.cwd(), ROOT);
|
||||||
|
if (!statSync(SRC_DIR, { throwIfNoParent: false })?.isDirectory?.()) {
|
||||||
|
console.error(`Cannot find source directory: ${SRC_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect all .tsx files under a directory, skipping build/scratch dirs. */
|
||||||
|
function collectTsx(dir, out = []) {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (entry === "node_modules" || entry === "dist" || entry === ".astryx-tmp") continue;
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const st = statSync(full);
|
||||||
|
if (st.isDirectory()) collectTsx(full, out);
|
||||||
|
else if (full.endsWith(".tsx") || full.endsWith(".ts")) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = collectTsx(SRC_DIR).sort();
|
||||||
|
const parsed = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
|
parsed.push({ file, text, sf });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AST helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Normalized attributes of a JSX element: [{key, value}] with literals resolved. */
|
||||||
|
function attrsOf(node) {
|
||||||
|
const out = [];
|
||||||
|
for (const prop of node.attributes.properties) {
|
||||||
|
if (!ts.isJsxAttribute(prop)) {
|
||||||
|
out.push({ key: "...", value: "..." }); // spread
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = prop.name.getText();
|
||||||
|
const init = prop.initializer;
|
||||||
|
if (!init) {
|
||||||
|
out.push({ key, value: "true" }); // boolean shorthand
|
||||||
|
} else if (ts.isStringLiteral(init)) {
|
||||||
|
out.push({ key, value: init.text });
|
||||||
|
} else if (ts.isJsxExpression(init)) {
|
||||||
|
const e = init.expression;
|
||||||
|
if (!e) out.push({ key, value: "expr" });
|
||||||
|
else if (ts.isStringLiteral(e) || ts.isNumericLiteral(e)) out.push({ key, value: e.text });
|
||||||
|
else if (e.kind === ts.SyntaxKind.TrueKeyword) out.push({ key, value: "true" });
|
||||||
|
else if (e.kind === ts.SyntaxKind.FalseKeyword) out.push({ key, value: "false" });
|
||||||
|
else out.push({ key, value: "expr" });
|
||||||
|
} else {
|
||||||
|
out.push({ key, value: "?" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a TSX subtree into element nodes:
|
||||||
|
* { tag, attrs, elemChildren, textCount, line, parent }
|
||||||
|
*/
|
||||||
|
function flattenJsx(node, sf, parent = null, out = []) {
|
||||||
|
if (ts.isJsxElement(node)) {
|
||||||
|
const el = {
|
||||||
|
tag: node.openingElement.tagName.getText(),
|
||||||
|
attrs: attrsOf(node.openingElement),
|
||||||
|
elemChildren: [],
|
||||||
|
textCount: 0,
|
||||||
|
line: node.getStart(sf).then ? 0 : sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
||||||
|
el.elemChildren.push(flattenJsx(child, sf, el, out));
|
||||||
|
} else if (ts.isJsxText(child) && child.getText().trim().length > 0) {
|
||||||
|
el.textCount += 1;
|
||||||
|
} else if (ts.isJsxExpression(child)) {
|
||||||
|
el.textCount += 1; // {expression} counts as dynamic text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
if (ts.isJsxSelfClosingElement(node)) {
|
||||||
|
const el = {
|
||||||
|
tag: node.tagName.getText(),
|
||||||
|
attrs: attrsOf(node),
|
||||||
|
elemChildren: [],
|
||||||
|
textCount: 0,
|
||||||
|
line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
out.push(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of parsed) {
|
||||||
|
p.elements = [];
|
||||||
|
const visit = (node) => {
|
||||||
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node))
|
||||||
|
flattenJsx(node, p.sf, null, p.elements);
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(p.sf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Layout idiom census — exact prop configs for layout primitives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const layoutCounts = new Map(); // sig -> {count, files:Set, examples:[{file,line}]}
|
||||||
|
for (const p of parsed) {
|
||||||
|
for (const el of p.elements) {
|
||||||
|
if (!LAYOUT_TAGS.has(el.tag)) continue;
|
||||||
|
const props = el.attrs
|
||||||
|
.filter((a) => a.key !== "...")
|
||||||
|
.map((a) => `${a.key}=${a.value}`)
|
||||||
|
.sort()
|
||||||
|
.join(",");
|
||||||
|
const sig = `${el.tag}(${props})`;
|
||||||
|
const entry = layoutCounts.get(sig) ?? { count: 0, files: new Set(), examples: [] };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.files.add(p.file);
|
||||||
|
if (entry.examples.length < 3) entry.examples.push({ file: p.file, line: el.line });
|
||||||
|
layoutCounts.set(sig, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Structural fingerprints — shape only, text/prop values stripped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MAX_DEPTH = 4;
|
||||||
|
|
||||||
|
function shapeSig(el, depth) {
|
||||||
|
const attrs = el.attrs
|
||||||
|
.map((a) => `${a.key}:${typeof a.value === "string" ? "s" : a.value}`)
|
||||||
|
.sort()
|
||||||
|
.join(",");
|
||||||
|
const kids = depth > 0 ? el.elemChildren.map((c) => shapeSig(c, depth - 1)).join(";") : "";
|
||||||
|
const text = el.textCount > 0 ? `#t${el.textCount}` : "";
|
||||||
|
return `${el.tag}(${attrs})[${kids}${text}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCounts = new Map(); // sig -> {count, files:Set, examples:[{file,line}]}
|
||||||
|
for (const p of parsed) {
|
||||||
|
for (const el of p.elements) {
|
||||||
|
const sig = shapeSig(el, MAX_DEPTH);
|
||||||
|
const entry = shapeCounts.get(sig) ?? { count: 0, files: new Set(), examples: [] };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.files.add(p.file);
|
||||||
|
if (entry.examples.length < 3) entry.examples.push({ file: p.file, line: el.line });
|
||||||
|
shapeCounts.set(sig, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Known idioms
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const getAttr = (el, key) => el.attrs.find((a) => a.key === key)?.value;
|
||||||
|
|
||||||
|
/** Text element with supporting typography + secondary color (the "subtitle"). */
|
||||||
|
const isSupporting = (el) =>
|
||||||
|
el.tag === "Text" &&
|
||||||
|
(getAttr(el, "type") === "supporting" || getAttr(el, "type") === undefined) &&
|
||||||
|
(getAttr(el, "color") === "secondary" || getAttr(el, "color") === undefined);
|
||||||
|
|
||||||
|
function detectIdioms() {
|
||||||
|
const results = {
|
||||||
|
labelPair: [], // VStack gap=0: [Text(label), Text(supporting/secondary)]
|
||||||
|
sectionHeader: [], // VStack gap=0: [Heading, Text(supporting/secondary)]
|
||||||
|
statCard: [], // Card padding>=3: VStack gap=1: [supporting, Heading, supporting]
|
||||||
|
iconTextRow: [], // HStack gap 1-3 vAlign=center: [Icon, VStack]
|
||||||
|
formActions: [], // HStack gap=2: [Button, Button, ...]
|
||||||
|
wizardProgress: [], // HStack wrap=wrap: several [Badge, Text] HStacks
|
||||||
|
};
|
||||||
|
const push = (key, p, el, label) =>
|
||||||
|
results[key].push({ file: p.file, line: el.line, detail: label });
|
||||||
|
|
||||||
|
for (const p of parsed) {
|
||||||
|
for (const el of p.elements) {
|
||||||
|
const kids = el.elemChildren;
|
||||||
|
|
||||||
|
// labelPair / sectionHeader
|
||||||
|
if (el.tag === "VStack" && getAttr(el, "gap") === "0" && kids.length === 2) {
|
||||||
|
const [a, b] = kids;
|
||||||
|
if (isSupporting(b) && a.tag === "Text") push("labelPair", p, el, "label+description");
|
||||||
|
if (isSupporting(b) && a.tag === "Heading")
|
||||||
|
push("sectionHeader", p, el, "heading+description");
|
||||||
|
}
|
||||||
|
|
||||||
|
// statCard
|
||||||
|
if (el.tag === "Card" && kids.length === 1) {
|
||||||
|
const inner = kids[0];
|
||||||
|
const pad = Number(getAttr(el, "padding") ?? "0");
|
||||||
|
if (inner.tag === "VStack" && getAttr(inner, "gap") === "1" && pad >= 3) {
|
||||||
|
const [t1, h, t2] = inner.elemChildren;
|
||||||
|
if (
|
||||||
|
t1?.tag === "Text" &&
|
||||||
|
isSupporting(t1) &&
|
||||||
|
h?.tag === "Heading" &&
|
||||||
|
t2?.tag === "Text" &&
|
||||||
|
isSupporting(t2)
|
||||||
|
) {
|
||||||
|
push("statCard", p, el, `Card padding=${pad} + VStack gap=1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconTextRow
|
||||||
|
if (el.tag === "HStack" && getAttr(el, "vAlign") === "center") {
|
||||||
|
const gap = Number(getAttr(el, "gap") ?? "0");
|
||||||
|
if (gap >= 1 && gap <= 3 && kids[0]?.tag === "Icon" && kids[1]?.tag === "VStack") {
|
||||||
|
push("iconTextRow", p, el, `Icon + VStack, gap=${gap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formActions
|
||||||
|
if (
|
||||||
|
el.tag === "HStack" &&
|
||||||
|
getAttr(el, "gap") === "2" &&
|
||||||
|
kids.length >= 2 &&
|
||||||
|
kids.every((k) => k.tag === "Button")
|
||||||
|
) {
|
||||||
|
push("formActions", p, el, `${kids.length} buttons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// wizardProgress
|
||||||
|
if (el.tag === "HStack" && getAttr(el, "wrap") === "wrap" && kids.length >= 2) {
|
||||||
|
const stepy = kids.every(
|
||||||
|
(k) =>
|
||||||
|
k.tag === "HStack" &&
|
||||||
|
k.elemChildren.some((c) => c.tag === "Badge") &&
|
||||||
|
k.elemChildren.some((c) => c.tag === "Text"),
|
||||||
|
);
|
||||||
|
if (stepy) push("wizardProgress", p, el, `${kids.length} steps`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idioms = detectIdioms();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. File similarity — Jaccard over shape fingerprints (depth 2..3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fileSigSet(p) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const el of p.elements) {
|
||||||
|
set.add(shapeSig(el, 3));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
const sigSets = new Map(parsed.map((p) => [p.file, fileSigSet(p)]));
|
||||||
|
|
||||||
|
const pairs = [];
|
||||||
|
const fileList = [...sigSets.keys()];
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const a = sigSets.get(fileList[i]);
|
||||||
|
for (let j = i + 1; j < fileList.length; j++) {
|
||||||
|
const b = sigSets.get(fileList[j]);
|
||||||
|
if (a.size < 15 || b.size < 15) continue;
|
||||||
|
let inter = 0;
|
||||||
|
for (const s of a) if (b.has(s)) inter += 1;
|
||||||
|
const union = a.size + b.size - inter;
|
||||||
|
const jac = inter / union;
|
||||||
|
if (jac >= 0.3 && inter >= 8) {
|
||||||
|
pairs.push({
|
||||||
|
a: fileList[i],
|
||||||
|
b: fileList[j],
|
||||||
|
jaccard: jac,
|
||||||
|
shared: inter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs.sort((x, y) => y.jaccard - x.jaccard || y.shared - x.shared);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Import cluster census
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const importModuleCount = new Map(); // module -> files
|
||||||
|
const coImportCount = new Map(); // "mod1|mod2" -> files
|
||||||
|
for (const p of parsed) {
|
||||||
|
const mods = new Set();
|
||||||
|
const visit = (node) => {
|
||||||
|
if (
|
||||||
|
ts.isImportDeclaration(node) &&
|
||||||
|
node.moduleSpecifier &&
|
||||||
|
ts.isStringLiteral(node.moduleSpecifier)
|
||||||
|
) {
|
||||||
|
const mod = node.moduleSpecifier.text;
|
||||||
|
if (mod.startsWith("@astryxdesign/core")) mods.add(mod);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(p.sf);
|
||||||
|
const list = [...mods].sort();
|
||||||
|
for (const m of list) {
|
||||||
|
const e = importModuleCount.get(m) ?? { count: 0, files: new Set() };
|
||||||
|
e.count += 1;
|
||||||
|
e.files.add(p.file);
|
||||||
|
importModuleCount.set(m, e);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
for (let j = i + 1; j < list.length; j++) {
|
||||||
|
const key = `${list[i]}|${list[j]}`;
|
||||||
|
const e = coImportCount.get(key) ?? { count: 0, files: new Set() };
|
||||||
|
e.count += 1;
|
||||||
|
e.files.add(p.file);
|
||||||
|
coImportCount.set(key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reporting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const rel = (f) => relative(process.cwd(), f);
|
||||||
|
const fmtList = (s) => {
|
||||||
|
const arr = [...s].map(rel).sort();
|
||||||
|
if (arr.length <= 5) return arr.join(", ");
|
||||||
|
return `${arr.slice(0, 5).join(", ")} … (+${arr.length - 5} more)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// --- layout census ---
|
||||||
|
const layoutRows = [...layoutCounts.entries()]
|
||||||
|
.filter(([, e]) => e.count >= MIN_COUNT && e.files.size >= 2)
|
||||||
|
.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
sections.push({
|
||||||
|
title: `Layout idiom census — exact VStack/HStack/Card/Grid/Section configs (≥ ${MIN_COUNT} uses, ≥ 2 files)`,
|
||||||
|
rows: layoutRows.slice(0, LIMIT).map(([sig, e]) => ({
|
||||||
|
signature: sig,
|
||||||
|
count: e.count,
|
||||||
|
files: e.files.size,
|
||||||
|
filesList: fmtList(e.files),
|
||||||
|
example: `${rel(e.examples[0].file)}:${e.examples[0].line}`,
|
||||||
|
})),
|
||||||
|
total: layoutRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- shape fingerprints ---
|
||||||
|
const shapeRows = [...shapeCounts.entries()]
|
||||||
|
.filter(([, e]) => e.count >= MIN_COUNT && e.files.size >= 2)
|
||||||
|
.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
sections.push({
|
||||||
|
title: `Repeated JSX structure fingerprints (≥ ${MIN_COUNT} occurrences, ≥ 2 files)`,
|
||||||
|
rows: shapeRows.slice(0, LIMIT).map(([sig, e]) => ({
|
||||||
|
signature: sig.length > 220 ? `${sig.slice(0, 220)}…` : sig,
|
||||||
|
count: e.count,
|
||||||
|
files: e.files.size,
|
||||||
|
filesList: fmtList(e.files),
|
||||||
|
example: `${rel(e.examples[0].file)}:${e.examples[0].line}`,
|
||||||
|
})),
|
||||||
|
total: shapeRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- idioms ---
|
||||||
|
const idiomNames = {
|
||||||
|
labelPair: "Label+description pair (VStack gap=0: Text + supporting Text)",
|
||||||
|
sectionHeader: "Section header (VStack gap=0: Heading + supporting Text)",
|
||||||
|
statCard: "Stat card (Card padding>=3 > VStack gap=1: 2 supporting Texts + Heading)",
|
||||||
|
iconTextRow: "Icon + text row (HStack vAlign=center: Icon + VStack)",
|
||||||
|
formActions: "Form action row (HStack gap=2: 2+ Buttons)",
|
||||||
|
wizardProgress: "Wizard progress (HStack wrap=wrap of Badge+Text steps)",
|
||||||
|
};
|
||||||
|
const idiomRows = Object.entries(idioms).map(([key, hits]) => {
|
||||||
|
const byFile = new Map();
|
||||||
|
for (const h of hits) {
|
||||||
|
if (!byFile.has(h.file)) byFile.set(h.file, h.line);
|
||||||
|
if (byFile.size >= 3) break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
idiom: idiomNames[key],
|
||||||
|
key,
|
||||||
|
count: hits.length,
|
||||||
|
files: new Set(hits.map((h) => h.file)).size,
|
||||||
|
examples: [...byFile.entries()].map(([f, l]) => `${rel(f)}:${l}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
sections.push({
|
||||||
|
title: "Known hand-rolled idioms — extraction candidates",
|
||||||
|
rows: idiomRows,
|
||||||
|
total: idiomRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- file similarity ---
|
||||||
|
sections.push({
|
||||||
|
title: "Most similar page pairs (Jaccard over structure fingerprints)",
|
||||||
|
rows: pairs.slice(0, LIMIT).map((p) => ({
|
||||||
|
pair: `${rel(p.a)} ↔ ${rel(p.b)}`,
|
||||||
|
jaccard: p.jaccard.toFixed(2),
|
||||||
|
shared: p.shared,
|
||||||
|
})),
|
||||||
|
total: pairs.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- import clusters ---
|
||||||
|
const coRows = [...coImportCount.entries()]
|
||||||
|
.filter(([, e]) => e.count >= 5)
|
||||||
|
.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
sections.push({
|
||||||
|
title: "Most co-imported @astryxdesign/core modules (signals composite components to extract)",
|
||||||
|
rows: coRows.slice(0, LIMIT).map(([key, e]) => ({
|
||||||
|
modules: key.replace("@astryxdesign/core", ""),
|
||||||
|
count: e.count,
|
||||||
|
files: e.files.size,
|
||||||
|
})),
|
||||||
|
total: coRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const summary = {
|
||||||
|
filesScanned: files.length,
|
||||||
|
elementsParsed: parsed.reduce((n, p) => n + p.elements.length, 0),
|
||||||
|
vstackUses: [...layoutCounts.entries()]
|
||||||
|
.filter(([s]) => s.startsWith("VStack"))
|
||||||
|
.reduce((n, [, e]) => n + e.count, 0),
|
||||||
|
hstackUses: [...layoutCounts.entries()]
|
||||||
|
.filter(([s]) => s.startsWith("HStack"))
|
||||||
|
.reduce((n, [, e]) => n + e.count, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (AS_JSON) {
|
||||||
|
console.log(JSON.stringify({ summary, sections }, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`\n=== Component duplication scanner ===`);
|
||||||
|
console.log(
|
||||||
|
`Scanned ${summary.filesScanned} files, ${summary.elementsParsed} JSX elements ` +
|
||||||
|
`(${summary.vstackUses} VStack, ${summary.hstackUses} HStack uses).\n`,
|
||||||
|
);
|
||||||
|
for (const s of sections) {
|
||||||
|
console.log(`\n## ${s.title} (${s.total} matches)\n`);
|
||||||
|
for (const r of s.rows) {
|
||||||
|
if (r.signature !== undefined) {
|
||||||
|
console.log(` ${r.count.toString().padStart(4)}× ${r.signature}`);
|
||||||
|
console.log(` in ${r.files} files · e.g. ${r.example}`);
|
||||||
|
} else if (r.idiom !== undefined) {
|
||||||
|
console.log(` ${r.count.toString().padStart(4)}× ${r.idiom}`);
|
||||||
|
console.log(` in ${r.files} files · e.g. ${r.examples.join(", ")}`);
|
||||||
|
} else if (r.pair !== undefined) {
|
||||||
|
console.log(` ${(r.jaccard * 100).toString().padStart(2)}% ${r.pair}`);
|
||||||
|
console.log(` ${r.shared} shared structure fingerprints`);
|
||||||
|
} else if (r.modules !== undefined) {
|
||||||
|
console.log(` ${r.count.toString().padStart(4)}× ${r.modules}`);
|
||||||
|
console.log(` in ${r.files} files`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface BackButtonProps {
|
||||||
|
/** Accessible label, e.g. "Back to Git connections". */
|
||||||
|
label: string;
|
||||||
|
/** Route to navigate back to. */
|
||||||
|
href: string;
|
||||||
|
/** Render as an icon-only ghost button. */
|
||||||
|
iconOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ghost "back to list" button with a left arrow, used at the top of detail
|
||||||
|
* pages and wizard pages. Extracted from the repeated `Button variant="ghost"`
|
||||||
|
* + `IconArrowLeft` + `navigate(href)` pattern.
|
||||||
|
*/
|
||||||
|
export function BackButton({ label, href, iconOnly = false }: BackButtonProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={label}
|
||||||
|
tooltip={label}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon icon={IconArrowLeft} size="sm" />}
|
||||||
|
isIconOnly={iconOnly}
|
||||||
|
onClick={() => navigate(href)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
import { SectionHeader } from "./SectionHeader";
|
||||||
|
|
||||||
|
interface DangerZoneProps {
|
||||||
|
/** Supporting description under the "Danger zone" heading. */
|
||||||
|
description: string;
|
||||||
|
/** Warning copy above the destructive action. */
|
||||||
|
warning: string;
|
||||||
|
/** Destructive button label (e.g. "Delete connection"). */
|
||||||
|
actionLabel: string;
|
||||||
|
/** Opens the confirmation dialog. */
|
||||||
|
onAction: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Danger zone" settings section: a two-column grid with a section header
|
||||||
|
* on the left and a warning + destructive action on the right. Extracted from
|
||||||
|
* the repeated danger-zone blocks on the provider/integration detail pages.
|
||||||
|
*/
|
||||||
|
export function DangerZone({ description, warning, actionLabel, onAction }: DangerZoneProps) {
|
||||||
|
return (
|
||||||
|
<Grid columns={{ minWidth: 320, max: 2 }} gap={10} align="start">
|
||||||
|
<SectionHeader title="Danger zone" description={description} gap={1} />
|
||||||
|
<VStack gap={3}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{warning}
|
||||||
|
</Text>
|
||||||
|
<HStack hAlign="start">
|
||||||
|
<Button
|
||||||
|
label={actionLabel}
|
||||||
|
variant="destructive"
|
||||||
|
icon={<Icon icon={IconTrash} size="sm" />}
|
||||||
|
onClick={onAction}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { AlertDialog } from "@astryxdesign/core/AlertDialog";
|
||||||
|
|
||||||
|
interface DeleteResourceDialogProps {
|
||||||
|
/** Whether the dialog is open. */
|
||||||
|
open: boolean;
|
||||||
|
/** Callback fired when the dialog visibility changes. */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Dialog title, e.g. "Delete Acme GitHub?" */
|
||||||
|
title: string;
|
||||||
|
/** Consequence description shown under the title. */
|
||||||
|
description: string;
|
||||||
|
/** Destructive action button label. */
|
||||||
|
actionLabel: string;
|
||||||
|
/** Called when the destructive action is confirmed. */
|
||||||
|
onConfirm: () => void;
|
||||||
|
/** Shows a loading spinner on the action button. */
|
||||||
|
isActionLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destructive confirmation dialog with the standard destructive action
|
||||||
|
* variant. Extracted from the repeated `AlertDialog actionVariant="destructive"`
|
||||||
|
* confirmations on the provider detail pages and organization containers.
|
||||||
|
*/
|
||||||
|
export function DeleteResourceDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onConfirm,
|
||||||
|
isActionLoading,
|
||||||
|
}: DeleteResourceDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
isOpen={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actionLabel={actionLabel}
|
||||||
|
actionVariant="destructive"
|
||||||
|
isActionLoading={isActionLoading}
|
||||||
|
onAction={onConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
|
||||||
|
/** Design-system spacing steps accepted by stack primitives. */
|
||||||
|
type SpacingStep = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10;
|
||||||
|
|
||||||
|
interface EntityRowProps {
|
||||||
|
/** Optional leading icon/avatar/badge. */
|
||||||
|
leading?: ReactNode;
|
||||||
|
/** Primary name/title text (rendered semibold). */
|
||||||
|
title: ReactNode;
|
||||||
|
/** Optional supporting subtitle line. */
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
/** Title text style variant (defaults to body). */
|
||||||
|
titleVariant?: "body" | "label";
|
||||||
|
/** Horizontal gap between leading and text (defaults to 3). */
|
||||||
|
gap?: SpacingStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon/avatar + name + subtitle row used as the leading cell in tables and
|
||||||
|
* list cards. Extracted from the recurring `HStack` + `VStack gap={0}` idiom
|
||||||
|
* across the provider list pages and feature cards.
|
||||||
|
*/
|
||||||
|
export function EntityRow({
|
||||||
|
leading,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
titleVariant = "body",
|
||||||
|
gap = 3,
|
||||||
|
}: EntityRowProps) {
|
||||||
|
return (
|
||||||
|
<HStack gap={gap} vAlign="center">
|
||||||
|
{leading}
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type={titleVariant} weight="semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface FormFooterProps {
|
||||||
|
/** Whether the last save succeeded; renders the "Saved" confirmation. */
|
||||||
|
saved?: boolean;
|
||||||
|
/** Text shown next to the check icon when saved (defaults to "Saved"). */
|
||||||
|
savedLabel?: string;
|
||||||
|
/** Primary action button label (e.g. "Save settings"). */
|
||||||
|
primaryLabel: string;
|
||||||
|
/** Primary action handler. */
|
||||||
|
onPrimary: () => void;
|
||||||
|
/** Disables the primary button (e.g. while submitting). */
|
||||||
|
primaryDisabled?: boolean;
|
||||||
|
/** Optional icon rendered inside the primary button. */
|
||||||
|
primaryIcon?: ReactNode;
|
||||||
|
/** Optional secondary action button label (e.g. "Cancel" / "Reset"). */
|
||||||
|
secondaryLabel?: string;
|
||||||
|
/** Secondary action handler. */
|
||||||
|
onSecondary?: () => void;
|
||||||
|
/** Disables the secondary button. */
|
||||||
|
secondaryDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-aligned form action bar: optional "Saved" confirmation, an optional
|
||||||
|
* secondary button, and a primary action. Extracted from the save footers on
|
||||||
|
* the provider/integration detail pages.
|
||||||
|
*/
|
||||||
|
export function FormFooter({
|
||||||
|
saved,
|
||||||
|
savedLabel = "Saved",
|
||||||
|
primaryLabel,
|
||||||
|
onPrimary,
|
||||||
|
primaryDisabled,
|
||||||
|
primaryIcon,
|
||||||
|
secondaryLabel,
|
||||||
|
onSecondary,
|
||||||
|
secondaryDisabled,
|
||||||
|
}: FormFooterProps) {
|
||||||
|
return (
|
||||||
|
<HStack hAlign="end" gap={2} vAlign="center" wrap="wrap">
|
||||||
|
{saved ? (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<Icon icon={IconCheck} size="sm" color="success" />
|
||||||
|
<Text type="supporting">{savedLabel}</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
{secondaryLabel ? (
|
||||||
|
<Button
|
||||||
|
label={secondaryLabel}
|
||||||
|
variant="secondary"
|
||||||
|
isDisabled={secondaryDisabled}
|
||||||
|
onClick={onSecondary}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
label={primaryLabel}
|
||||||
|
variant="primary"
|
||||||
|
icon={primaryIcon}
|
||||||
|
isDisabled={primaryDisabled}
|
||||||
|
onClick={onPrimary}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Card, type CardProps } from "@astryxdesign/core/Card";
|
||||||
|
import { colorVars, radiusVars } from "@astryxdesign/core/theme/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
panel: {
|
||||||
|
backgroundColor: colorVars["--color-background-surface"],
|
||||||
|
borderRadius: radiusVars["--radius-none"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface OutlinedPanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
padding?: CardProps["padding"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square, bordered page region that stays inside its parent content gutter. */
|
||||||
|
export function OutlinedPanel({ children, padding = 6 }: OutlinedPanelProps) {
|
||||||
|
return (
|
||||||
|
<Card width="100%" padding={padding} elevation="none" xstyle={styles.panel}>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon, type IconType } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, StackItem, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { SelectableCard } from "@astryxdesign/core/SelectableCard";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
|
||||||
|
interface ProviderPickerDefinition {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: IconType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderPickerProps<T extends string> {
|
||||||
|
/** Kind → definition registry (e.g. gitProviderDefinitions). */
|
||||||
|
definitions: Record<T, ProviderPickerDefinition>;
|
||||||
|
/** Currently selected kind. */
|
||||||
|
selected: T;
|
||||||
|
/** Called when a card is selected. */
|
||||||
|
onSelect: (kind: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid of selectable provider-kind cards, the first step of the provider
|
||||||
|
* wizard pages. Extracted from the byte-identical `SelectableCard` grids on
|
||||||
|
* the Git/Issue/Integration provider wizards.
|
||||||
|
*/
|
||||||
|
export function ProviderPicker<T extends string>({
|
||||||
|
definitions,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: ProviderPickerProps<T>) {
|
||||||
|
return (
|
||||||
|
<Grid columns={{ minWidth: 280, max: 2 }} gap={4}>
|
||||||
|
{(Object.keys(definitions) as T[]).map((kind) => {
|
||||||
|
const definition = definitions[kind];
|
||||||
|
return (
|
||||||
|
<SelectableCard
|
||||||
|
key={kind}
|
||||||
|
label={definition.label}
|
||||||
|
isSelected={selected === kind}
|
||||||
|
onChange={() => onSelect(kind)}
|
||||||
|
padding={5}
|
||||||
|
>
|
||||||
|
<HStack gap={4} vAlign="start">
|
||||||
|
<Icon icon={definition.icon} size="lg" color="secondary" />
|
||||||
|
<StackItem size="fill">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>{definition.label}</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{definition.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
</HStack>
|
||||||
|
</SelectableCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { HStack, Section, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
|
||||||
|
interface ReviewSummaryProps {
|
||||||
|
/** Leading icon next to the summary title. */
|
||||||
|
icon: ReactNode;
|
||||||
|
/** Summary title (e.g. the provider name). */
|
||||||
|
title: ReactNode;
|
||||||
|
/** Supporting line under the title (e.g. "GitHub · https://github.com"). */
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
/** Label/value pairs rendered in a two-column grid. */
|
||||||
|
rows: { label: ReactNode; value: ReactNode }[];
|
||||||
|
/** Optional trailing content (e.g. an info Banner). */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Minimum grid column width (defaults to 220). */
|
||||||
|
minWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The final "review" step of the provider wizard pages: a muted section with
|
||||||
|
* an icon + title + subtitle header, a divider, and a label/value grid.
|
||||||
|
* Extracted from the byte-identical review sections on the Git/Issue/
|
||||||
|
* Integration provider wizards.
|
||||||
|
*/
|
||||||
|
export function ReviewSummary({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
rows,
|
||||||
|
footer,
|
||||||
|
minWidth = 220,
|
||||||
|
}: ReviewSummaryProps) {
|
||||||
|
return (
|
||||||
|
<Section variant="muted" padding={5}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
{icon}
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={3}>{title}</Heading>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<Divider />
|
||||||
|
<Grid columns={{ minWidth, max: 2 }} gap={4}>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<VStack key={index} gap={0}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{row.label}
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{row.value}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{footer}
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import type { HeadingLevel } from "@astryxdesign/core/Heading";
|
||||||
|
|
||||||
|
/** Design-system spacing steps accepted by layout primitives. */
|
||||||
|
type SpacingStep = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10;
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
/** Section title rendered as a Heading. */
|
||||||
|
title: ReactNode;
|
||||||
|
/** Supporting description shown under the title. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Typography variant for the description (defaults to supporting). */
|
||||||
|
descriptionType?: "supporting" | "body";
|
||||||
|
/** Optional action rendered to the right of the header. */
|
||||||
|
endAction?: ReactNode;
|
||||||
|
/** Heading level (defaults to 2). */
|
||||||
|
headingLevel?: HeadingLevel;
|
||||||
|
/** Vertical gap between title and description (defaults to 0). */
|
||||||
|
gap?: SpacingStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title + description block that precedes a section, with an optional trailing
|
||||||
|
* action. Extracted from the recurring `VStack` header idiom used across list
|
||||||
|
* pages, detail pages, and settings sections.
|
||||||
|
*/
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
descriptionType = "supporting",
|
||||||
|
endAction,
|
||||||
|
headingLevel = 2,
|
||||||
|
gap = 0,
|
||||||
|
}: SectionHeaderProps) {
|
||||||
|
const header = (
|
||||||
|
<VStack gap={gap}>
|
||||||
|
<Heading level={headingLevel}>{title}</Heading>
|
||||||
|
{description ? (
|
||||||
|
<Text type={descriptionType} color="secondary">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!endAction) return header;
|
||||||
|
return (
|
||||||
|
<HStack gap={4} hAlign="between" vAlign="end" wrap="wrap">
|
||||||
|
{header}
|
||||||
|
{endAction}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
|
||||||
|
/** Design-system spacing steps accepted by Card. */
|
||||||
|
type SpacingStep = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10;
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
/** Supporting label above the value. */
|
||||||
|
label: ReactNode;
|
||||||
|
/** The metric value rendered as a level-2 heading. */
|
||||||
|
value: ReactNode;
|
||||||
|
/** Optional supporting hint under the value. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Card padding (defaults to 4). */
|
||||||
|
padding?: SpacingStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metric tile used in stat-card grids: `Card padding={4}` with a supporting
|
||||||
|
* label, a level-2 heading value, and an optional hint. Extracted from the
|
||||||
|
* byte-identical stat cards on the provider/integration list and detail pages.
|
||||||
|
*
|
||||||
|
* Pair with a `Grid columns={{ minWidth: 180, max: 4 }}` row for the standard
|
||||||
|
* KPI grid.
|
||||||
|
*/
|
||||||
|
export function StatCard({ label, value, hint, padding = 4 }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card padding={padding}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Heading level={2}>{value}</Heading>
|
||||||
|
{hint ? (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardGridProps {
|
||||||
|
/** Stat card definitions. */
|
||||||
|
cards: StatCardProps[];
|
||||||
|
/** Minimum column width in the responsive grid (defaults to 180). */
|
||||||
|
minWidth?: number;
|
||||||
|
/** Maximum number of columns (defaults to 4). */
|
||||||
|
max?: number;
|
||||||
|
/** Grid gap (defaults to 4). */
|
||||||
|
gap?: SpacingStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsive `Grid` of `StatCard`s, the standard KPI row on list and detail
|
||||||
|
* pages. Extracted from the repeated `Grid columns={{ minWidth, max }}`
|
||||||
|
* wrappers around stat-card blocks.
|
||||||
|
*/
|
||||||
|
export function StatCardGrid({ cards, minWidth = 180, max = 4, gap = 4 }: StatCardGridProps) {
|
||||||
|
return (
|
||||||
|
<Grid columns={{ minWidth, max }} gap={gap}>
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<StatCard key={index} {...card} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot, type StatusDotVariant } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
|
||||||
|
interface StatusCellProps {
|
||||||
|
/** Semantic dot variant. */
|
||||||
|
variant: StatusDotVariant;
|
||||||
|
/** Status label; used for both the dot's a11y label and the visible text. */
|
||||||
|
label: string;
|
||||||
|
/** Whether the dot should pulse to indicate activity. */
|
||||||
|
isPulsing?: boolean;
|
||||||
|
/** Optional second supporting line under the status. */
|
||||||
|
detail?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status indicator for table cells: `StatusDot` + semibold label, with an
|
||||||
|
* optional secondary detail line underneath. Extracted from the recurring
|
||||||
|
* status columns across the provider/integration list pages.
|
||||||
|
*/
|
||||||
|
export function StatusCell({ variant, label, isPulsing, detail }: StatusCellProps) {
|
||||||
|
return (
|
||||||
|
<VStack gap={0}>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot variant={variant} label={label} isPulsing={isPulsing} />
|
||||||
|
<Text type="supporting" weight="semibold">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{detail ? (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Table, type TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import { SectionHeader } from "./SectionHeader";
|
||||||
|
|
||||||
|
interface TableSectionProps<T extends Record<string, unknown>> {
|
||||||
|
/** Section title rendered above the table. */
|
||||||
|
title: ReactNode;
|
||||||
|
/** Supporting description under the title. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Optional actions rendered to the right of the header (filters, refresh…). */
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
/** Table column definitions. */
|
||||||
|
columns: TableColumn<T>[];
|
||||||
|
/** Table rows. */
|
||||||
|
data?: T[];
|
||||||
|
/** Row key for React reconciliation. */
|
||||||
|
idKey?: (keyof T & string) | ((item: T) => string | number);
|
||||||
|
/** Rendered inside the card instead of the table when data is empty. */
|
||||||
|
emptyState?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section header + `Card padding={0}` + `Table` with the standard prop set
|
||||||
|
* (`density="balanced"`, `dividers="rows"`, `verticalAlign="middle"`,
|
||||||
|
* `textOverflow="truncate"`, `hasHover`) used across the provider/integration
|
||||||
|
* list pages. Extracted from the repeated `VStack gap={3}` + header + card
|
||||||
|
* table blocks.
|
||||||
|
*/
|
||||||
|
export function TableSection<T extends Record<string, unknown>>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
toolbar,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
idKey,
|
||||||
|
emptyState,
|
||||||
|
}: TableSectionProps<T>) {
|
||||||
|
return (
|
||||||
|
<VStack gap={3}>
|
||||||
|
<SectionHeader title={title} description={description} endAction={toolbar} />
|
||||||
|
<Card padding={0}>
|
||||||
|
{emptyState && data && data.length === 0 ? (
|
||||||
|
emptyState
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
idKey={idKey}
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
verticalAlign="middle"
|
||||||
|
textOverflow="truncate"
|
||||||
|
hasHover
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface WizardProgressProps {
|
||||||
|
/** Step labels in order. */
|
||||||
|
steps: readonly string[];
|
||||||
|
/** Current step, 1-based. */
|
||||||
|
current: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge + label step indicator used as the `PageHero` action slot on the
|
||||||
|
* provider wizard pages. Completed steps render a success badge with a check
|
||||||
|
* icon, the current step is highlighted, and future steps are neutral.
|
||||||
|
*/
|
||||||
|
export function WizardProgress({ steps, current }: WizardProgressProps) {
|
||||||
|
return (
|
||||||
|
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||||
|
{steps.map((label, index) => {
|
||||||
|
const stepNumber = index + 1;
|
||||||
|
const isComplete = stepNumber < current;
|
||||||
|
const isCurrent = stepNumber === current;
|
||||||
|
return (
|
||||||
|
<HStack key={label} gap={2} vAlign="center">
|
||||||
|
<Badge
|
||||||
|
variant={isComplete ? "success" : isCurrent ? "blue" : "neutral"}
|
||||||
|
label={isComplete ? <Icon icon={IconCheck} size="xsm" /> : String(stepNumber)}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="supporting"
|
||||||
|
weight={isCurrent ? "bold" : undefined}
|
||||||
|
color={isCurrent ? "primary" : "secondary"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const TONE_COLOR = {
|
||||||
|
primary: "var(--color-icon-primary)",
|
||||||
|
secondary: "var(--color-icon-secondary)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an ACP agent's registry logo as a monochrome, theme-aware glyph.
|
||||||
|
*
|
||||||
|
* The registry SVGs are single-color `currentColor` shapes with transparent
|
||||||
|
* backgrounds, so they are drawn as CSS masks tinted with the theme's icon
|
||||||
|
* color token — dark icons in light mode, light icons in dark mode,
|
||||||
|
* automatically, with no per-theme branching.
|
||||||
|
*/
|
||||||
|
export function AcpAgentIcon({
|
||||||
|
icon,
|
||||||
|
name,
|
||||||
|
size = 20,
|
||||||
|
tone = "primary",
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
tone?: keyof typeof TONE_COLOR;
|
||||||
|
}) {
|
||||||
|
if (!icon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={`${name} logo`}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: TONE_COLOR[tone],
|
||||||
|
WebkitMaskImage: `url("${icon}")`,
|
||||||
|
maskImage: `url("${icon}")`,
|
||||||
|
WebkitMaskRepeat: "no-repeat",
|
||||||
|
maskRepeat: "no-repeat",
|
||||||
|
WebkitMaskPosition: "center",
|
||||||
|
maskPosition: "center",
|
||||||
|
WebkitMaskSize: "contain",
|
||||||
|
maskSize: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { CodeBlock } from "@astryxdesign/core/CodeBlock";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { buildLaunchCommand, detectPlatform } from "../lib/launch";
|
||||||
|
import type { AcpAgent } from "../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the copyable launch snippet for an ACP agent, resolved against the
|
||||||
|
* user's detected platform. Falls back to a message when the agent ships no
|
||||||
|
* launch instructions at all.
|
||||||
|
*/
|
||||||
|
export function AcpLaunchInstructions({
|
||||||
|
agent,
|
||||||
|
width = "100%",
|
||||||
|
}: {
|
||||||
|
agent: AcpAgent;
|
||||||
|
width?: string;
|
||||||
|
}) {
|
||||||
|
const launch = useMemo(() => buildLaunchCommand(agent, detectPlatform()), [agent]);
|
||||||
|
|
||||||
|
if (!launch) {
|
||||||
|
return (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
No launch instructions available for this agent.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<CodeBlock
|
||||||
|
code={launch.script}
|
||||||
|
language="bash"
|
||||||
|
title={`${agent.name} · ${launch.sourceLabel}`}
|
||||||
|
container="section"
|
||||||
|
width={width}
|
||||||
|
isWrapped
|
||||||
|
/>
|
||||||
|
{launch.note ? (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{launch.note}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { Item } from "@astryxdesign/core/Item";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Popover } from "@astryxdesign/core/Popover";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconListCheck } from "@tabler/icons-react";
|
||||||
|
import { styles, type AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function ActiveTasks({ agent, metric = false }: { agent: AgentRow; metric?: boolean }) {
|
||||||
|
if (agent.activeTaskTitles.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text type={metric ? "large" : "supporting"} weight={metric ? "bold" : undefined}>
|
||||||
|
0
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
placement="below"
|
||||||
|
alignment="start"
|
||||||
|
width={280}
|
||||||
|
label={`Active tasks for ${agent.name}`}
|
||||||
|
content={
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Text type="label">Active tasks</Text>
|
||||||
|
<VStack gap={0}>
|
||||||
|
{agent.activeTaskTitles.map((task) => (
|
||||||
|
<Item
|
||||||
|
key={task}
|
||||||
|
density="compact"
|
||||||
|
label={task}
|
||||||
|
startContent={<Icon icon={IconListCheck} size="sm" color="secondary" />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{metric ? (
|
||||||
|
({ ref, onClick, ...triggerProps }) => (
|
||||||
|
<Item
|
||||||
|
{...triggerProps}
|
||||||
|
ref={ref}
|
||||||
|
density="compact"
|
||||||
|
label={
|
||||||
|
<Text type="large" weight="bold">
|
||||||
|
{agent.activeTasks}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
onClick={onClick}
|
||||||
|
xstyle={styles.metricTrigger}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={`${agent.activeTasks} task${agent.activeTasks === 1 ? "" : "s"}`}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Section } from "@astryxdesign/core/Section";
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||||
|
import { styles, type AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function ActivityChart({ agent }: { agent: AgentRow }) {
|
||||||
|
const data = agent.activity.map((value, index) => ({ index, value }));
|
||||||
|
return (
|
||||||
|
<Section variant="transparent" padding={0} xstyle={styles.activityChart}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`agent-activity-${agent.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--color-border-purple)" stopOpacity={0.55} />
|
||||||
|
<stop offset="100%" stopColor="var(--color-background-purple)" stopOpacity={0.08} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis hide domain={[0, 100]} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
fill={`url(#agent-activity-${agent.id})`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function ActivityTrendCard({ agent }: { agent: AgentRow }) {
|
||||||
|
const data = agent.activity.map((point, index) => ({ index, point }));
|
||||||
|
return (
|
||||||
|
<Card padding={5}>
|
||||||
|
<VStack gap={5}>
|
||||||
|
<HStack hAlign="between" vAlign="center" wrap="wrap">
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={2}>Activity</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Last 7 days
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{agent.completedTasks} runs completed
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`trend-${agent.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--color-border-purple)" stopOpacity={0.55} />
|
||||||
|
<stop offset="100%" stopColor="var(--color-background-purple)" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="point"
|
||||||
|
stroke="var(--color-border-purple)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={`url(#trend-${agent.id})`}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { statusDotVariant, statusLabel, type AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function AgentAvatar({ agent }: { agent: AgentRow }) {
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
src={agent.imageUrl}
|
||||||
|
name={agent.name}
|
||||||
|
alt={agent.name}
|
||||||
|
size="lg"
|
||||||
|
tooltip={false}
|
||||||
|
status={
|
||||||
|
<StatusDot variant={statusDotVariant[agent.status]} label={statusLabel[agent.status]} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ClickableCard } from "@astryxdesign/core/ClickableCard";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, StackItem, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Section } from "@astryxdesign/core/Section";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconActivityHeartbeat } from "@tabler/icons-react";
|
||||||
|
import { backendLabel, modelLabel } from "../lib/backend-label";
|
||||||
|
import { styles, type AgentRow } from "../lib/agent-rows";
|
||||||
|
import { ActiveTasks } from "./ActiveTasks";
|
||||||
|
import { ActivityChart } from "./ActivityChart";
|
||||||
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { AgentStatusLabel } from "./AgentStatusLabel";
|
||||||
|
import { BackendLogo } from "./BackendLogo";
|
||||||
|
|
||||||
|
function compactHeartbeat(value: string) {
|
||||||
|
if (value === "Never") return "—";
|
||||||
|
const match = value.match(/^(\d+)\s+(second|minute|hour)/);
|
||||||
|
if (!match) return value;
|
||||||
|
const unit = match[2] === "second" ? "s" : match[2] === "minute" ? "m" : "h";
|
||||||
|
return `${match[1]}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentCard({ agent }: { agent: AgentRow }) {
|
||||||
|
return (
|
||||||
|
<ClickableCard
|
||||||
|
label={`Open ${agent.name}`}
|
||||||
|
href={`/agents/${agent.id}/overview`}
|
||||||
|
padding={4}
|
||||||
|
xstyle={styles.agentCard}
|
||||||
|
>
|
||||||
|
<VStack gap={3} xstyle={styles.cardContent}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<AgentAvatar agent={agent} />
|
||||||
|
<StackItem size="fill">
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={3} maxLines={1}>
|
||||||
|
{agent.name}
|
||||||
|
</Heading>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<BackendLogo backend={agent.backend} />
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1}>
|
||||||
|
{backendLabel(agent.backend)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text type="supporting" size="sm" color="secondary" maxLines={1}>
|
||||||
|
{modelLabel(agent.modelOverride)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
<AgentStatusLabel agent={agent} />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text type="body" color="secondary" maxLines={1}>
|
||||||
|
{agent.description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section variant="muted" padding={3} xstyle={styles.activityPanel}>
|
||||||
|
<ActivityChart agent={agent} />
|
||||||
|
<HStack gap={4} hAlign="between" vAlign="center" xstyle={styles.activityContent}>
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<ActiveTasks agent={agent} metric />
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Active tasks
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<Icon icon={IconActivityHeartbeat} size="xsm" color="secondary" />
|
||||||
|
<Text type="large" weight="bold">
|
||||||
|
{compactHeartbeat(agent.lastHeartbeat)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Section>
|
||||||
|
</VStack>
|
||||||
|
</ClickableCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { HStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function AgentPresence({ agent, isPaused }: { agent: AgentRow; isPaused: boolean }) {
|
||||||
|
const label = isPaused
|
||||||
|
? "Paused"
|
||||||
|
: agent.status === "active"
|
||||||
|
? "Active"
|
||||||
|
: agent.status === "idle"
|
||||||
|
? "Idle"
|
||||||
|
: "Draft";
|
||||||
|
return (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot
|
||||||
|
variant={!isPaused && agent.status === "active" ? "success" : "neutral"}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
<Text type="body">{label}</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { statusLabel, type AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function AgentStatusLabel({ agent }: { agent: AgentRow }) {
|
||||||
|
return <Text type="supporting">{statusLabel[agent.status]}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { AcpAgentIcon } from "./AcpAgentIcon";
|
||||||
|
import type { AcpBackend } from "../lib/types";
|
||||||
|
|
||||||
|
/** Small ACP registry logo next to a backend label. */
|
||||||
|
export function BackendLogo({ backend }: { backend?: AcpBackend }) {
|
||||||
|
if (!backend) return null;
|
||||||
|
return <AcpAgentIcon icon={backend.icon} name={backend.agentName} size={20} tone="secondary" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
|
||||||
|
export function OverviewKpis({ agent }: { agent: AgentRow }) {
|
||||||
|
const metrics: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
tone: "up" | "down" | "flat";
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: "Runs today",
|
||||||
|
value: String(agent.activeTasks + 6),
|
||||||
|
detail: "+2 today",
|
||||||
|
tone: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active now",
|
||||||
|
value: String(agent.activeTasks),
|
||||||
|
detail: "running",
|
||||||
|
tone: "flat",
|
||||||
|
},
|
||||||
|
{ label: "Success rate", value: "97%", detail: "+1.4%", tone: "up" },
|
||||||
|
{ label: "Average run", value: "4m 18s", detail: "−32s", tone: "down" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Grid columns={{ minWidth: 220, max: 4 }} gap={4} align="stretch">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<Card key={metric.label} padding={5}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||||
|
<Text type="display-3" weight="bold">
|
||||||
|
{metric.value}
|
||||||
|
</Text>
|
||||||
|
{metric.tone === "flat" ? (
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{metric.detail}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<Icon
|
||||||
|
icon={metric.tone === "up" ? IconTrendingUp : IconTrendingDown}
|
||||||
|
size="xsm"
|
||||||
|
color={metric.tone === "up" ? "success" : "error"}
|
||||||
|
/>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{metric.detail}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{metric.label}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { pixel, proportional, type TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { backendLabel, modelLabel } from "../lib/backend-label";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
import { ActiveTasks } from "./ActiveTasks";
|
||||||
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { AgentStatusLabel } from "./AgentStatusLabel";
|
||||||
|
import { BackendLogo } from "./BackendLogo";
|
||||||
|
|
||||||
|
export const columns: TableColumn<AgentRow>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Agent",
|
||||||
|
width: proportional(2),
|
||||||
|
renderCell: (agent) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<AgentAvatar agent={agent} />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{agent.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1}>
|
||||||
|
{agent.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: pixel(100),
|
||||||
|
renderCell: (agent) => <AgentStatusLabel agent={agent} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "backend",
|
||||||
|
header: "Backend",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (agent) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<BackendLogo backend={agent.backend} />
|
||||||
|
<Text type="body" size="sm" weight="bold">
|
||||||
|
{backendLabel(agent.backend)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
header: "Model",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (agent) => (
|
||||||
|
<Text type="body" size="sm">
|
||||||
|
{modelLabel(agent.modelOverride)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activeTasks",
|
||||||
|
header: "Active tasks",
|
||||||
|
width: pixel(110),
|
||||||
|
renderCell: (agent) => <ActiveTasks agent={agent} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastHeartbeat",
|
||||||
|
header: "Last heartbeat",
|
||||||
|
width: pixel(150),
|
||||||
|
renderCell: (agent) => <Text type="supporting">{agent.lastHeartbeat}</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Outlet, useParams } from "react-router";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Center } from "@astryxdesign/core/Center";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { Link } from "@astryxdesign/core/Link";
|
||||||
|
import { HStack, Layout, LayoutContent, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Tab } from "@astryxdesign/core/TabList";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconListCheck,
|
||||||
|
IconPlayerPause,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconSettings,
|
||||||
|
IconUsersGroup,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { DetailHero } from "../../../layouts/DetailHero";
|
||||||
|
import { HeroTabs } from "../../../layouts/HeroTabs";
|
||||||
|
import { useRouteSection } from "../../../hooks/use-route-section";
|
||||||
|
import { backendLabel, modelLabel } from "../lib/backend-label";
|
||||||
|
import { AgentPresence } from "../components/AgentPresence";
|
||||||
|
import { BackendLogo } from "../components/BackendLogo";
|
||||||
|
import { agentRows } from "../lib/agent-rows";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
import type { AgentSection } from "../lib/agent-details";
|
||||||
|
|
||||||
|
/** Shared state passed to the agent detail section routes. */
|
||||||
|
export interface AgentDetailsContext {
|
||||||
|
agent: AgentRow;
|
||||||
|
isPaused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentDetailsPage() {
|
||||||
|
const { agentId } = useParams();
|
||||||
|
const agent = agentRows.find((candidate) => candidate.id === agentId);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const { activeSection, goToSection } = useRouteSection({
|
||||||
|
sections: ["tasks", "permissions", "settings"] as const,
|
||||||
|
fallback: "overview",
|
||||||
|
basePath: `/agents/${agentId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
content={
|
||||||
|
<LayoutContent padding={6}>
|
||||||
|
<Center axis="both" width="100%" height="100%">
|
||||||
|
<VStack gap={3} hAlign="center">
|
||||||
|
<Heading level={1}>Agent not found</Heading>
|
||||||
|
<Link href="/agents">Back to agents</Link>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
content={
|
||||||
|
<LayoutContent padding={6}>
|
||||||
|
<VStack gap={6}>
|
||||||
|
<DetailHero
|
||||||
|
breadcrumbs={
|
||||||
|
<Link href="/agents" color="secondary">
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<Icon icon={IconArrowLeft} size="sm" color="inherit" />
|
||||||
|
Agents
|
||||||
|
</HStack>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
leading={
|
||||||
|
<Avatar
|
||||||
|
src={agent.imageUrl}
|
||||||
|
name={agent.name}
|
||||||
|
alt={agent.name}
|
||||||
|
size="lg"
|
||||||
|
tooltip={false}
|
||||||
|
status={
|
||||||
|
<StatusDot
|
||||||
|
variant={!isPaused && agent.status === "active" ? "success" : "neutral"}
|
||||||
|
label={isPaused ? "Paused" : agent.status}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={agent.name}
|
||||||
|
metadata={
|
||||||
|
<>
|
||||||
|
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||||
|
<AgentPresence agent={agent} isPaused={isPaused} />
|
||||||
|
{agent.backend ? (
|
||||||
|
<>
|
||||||
|
<BackendLogo backend={agent.backend} />
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{backendLabel(agent.backend)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
No backend configured
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{modelLabel(agent.modelOverride)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text type="body" color="secondary" maxLines={1}>
|
||||||
|
{agent.description}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeroTabs
|
||||||
|
label="Agent sections"
|
||||||
|
value={activeSection}
|
||||||
|
onChange={(value) => goToSection(value as AgentSection)}
|
||||||
|
endContent={
|
||||||
|
<Button
|
||||||
|
label={isPaused ? "Resume agent" : "Pause agent"}
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={isPaused ? IconPlayerPlay : IconPlayerPause} size="sm" />}
|
||||||
|
onClick={() => setIsPaused((paused) => !paused)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="overview"
|
||||||
|
label="Overview"
|
||||||
|
icon={<Icon icon={IconLayoutDashboard} size="sm" />}
|
||||||
|
/>
|
||||||
|
<Tab value="tasks" label="Tasks" icon={<Icon icon={IconListCheck} size="sm" />} />
|
||||||
|
<Tab
|
||||||
|
value="permissions"
|
||||||
|
label="Team access"
|
||||||
|
icon={<Icon icon={IconUsersGroup} size="sm" />}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="settings"
|
||||||
|
label="Settings"
|
||||||
|
icon={<Icon icon={IconSettings} size="sm" />}
|
||||||
|
/>
|
||||||
|
</HeroTabs>
|
||||||
|
|
||||||
|
<Outlet context={{ agent, isPaused }} />
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Banner } from "@astryxdesign/core/Banner";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Section } from "@astryxdesign/core/Section";
|
||||||
|
import { Selector } from "@astryxdesign/core/Selector";
|
||||||
|
import { Spinner } from "@astryxdesign/core/Spinner";
|
||||||
|
import { Switch } from "@astryxdesign/core/Switch";
|
||||||
|
import { TextArea } from "@astryxdesign/core/TextArea";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { useToast } from "@astryxdesign/core/Toast";
|
||||||
|
import { IconBrandGithub, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { AcpAgentIcon } from "../components/AcpAgentIcon";
|
||||||
|
import { AcpLaunchInstructions } from "../components/AcpLaunchInstructions";
|
||||||
|
import { backendLabel } from "../lib/backend-label";
|
||||||
|
import { hasDistribution } from "../lib/launch";
|
||||||
|
import {
|
||||||
|
ENVIRONMENT_SOURCE_OPTIONS,
|
||||||
|
HEARTBEAT_OPTIONS,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
initialSettingsValues,
|
||||||
|
type SettingsValues,
|
||||||
|
} from "../lib/agent-details";
|
||||||
|
import type { AgentRow } from "../lib/agent-rows";
|
||||||
|
import { useAcpRegistry } from "../lib/registry";
|
||||||
|
import type { AcpBackend } from "../lib/types";
|
||||||
|
import {
|
||||||
|
CONFIGURED_LLM_PROVIDERS,
|
||||||
|
defaultAgentEnvironment,
|
||||||
|
environmentValueLabel,
|
||||||
|
type AgentEnvironmentValue,
|
||||||
|
} from "../../llm-providers/lib/data";
|
||||||
|
import { useLlmProviders } from "../../llm-providers/lib/store";
|
||||||
|
|
||||||
|
export function AgentSettings({ agent }: { agent: AgentRow }) {
|
||||||
|
const registry = useAcpRegistry();
|
||||||
|
const llmProviders = useLlmProviders();
|
||||||
|
const showToast = useToast();
|
||||||
|
const [saved, setSaved] = useState<SettingsValues>(() => initialSettingsValues(agent));
|
||||||
|
const [acpAgentId, setAcpAgentId] = useState(saved.acpAgentId);
|
||||||
|
const [useModelOverride, setUseModelOverride] = useState(saved.useModelOverride);
|
||||||
|
const [provider, setProvider] = useState(saved.provider);
|
||||||
|
const [model, setModel] = useState(saved.model);
|
||||||
|
const [environment, setEnvironment] = useState(saved.environment);
|
||||||
|
const [repository, setRepository] = useState(saved.repository);
|
||||||
|
const [heartbeat, setHeartbeat] = useState(saved.heartbeat);
|
||||||
|
const [defaultStatus, setDefaultStatus] = useState(saved.defaultStatus);
|
||||||
|
const [autoResume, setAutoResume] = useState(saved.autoResume);
|
||||||
|
const [instructions, setInstructions] = useState(saved.instructions);
|
||||||
|
|
||||||
|
const selectedAcpAgent = registry.agents.find((candidate) => candidate.id === acpAgentId);
|
||||||
|
const currentBackend: AcpBackend | undefined = selectedAcpAgent
|
||||||
|
? {
|
||||||
|
agentId: selectedAcpAgent.id,
|
||||||
|
agentName: selectedAcpAgent.name,
|
||||||
|
version: selectedAcpAgent.version,
|
||||||
|
icon: selectedAcpAgent.icon,
|
||||||
|
}
|
||||||
|
: agent.backend;
|
||||||
|
const selectedLlmProvider =
|
||||||
|
llmProviders.find((candidate) => candidate.id === provider) ??
|
||||||
|
llmProviders[0] ??
|
||||||
|
CONFIGURED_LLM_PROVIDERS[0]!;
|
||||||
|
const selectedModel =
|
||||||
|
selectedLlmProvider.models.find((candidate) => candidate.id === model) ??
|
||||||
|
selectedLlmProvider.models[0];
|
||||||
|
const providerOptions = llmProviders
|
||||||
|
.filter((candidate) => candidate.enabled)
|
||||||
|
.map((candidate) => ({ label: candidate.name, value: candidate.id }));
|
||||||
|
const modelOptions = selectedLlmProvider.models
|
||||||
|
.filter((candidate) => candidate.enabled && candidate.availability === "available")
|
||||||
|
.map((candidate) => ({ label: candidate.name, value: candidate.id }));
|
||||||
|
|
||||||
|
const values: SettingsValues = {
|
||||||
|
acpAgentId,
|
||||||
|
useModelOverride,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
environment,
|
||||||
|
repository,
|
||||||
|
heartbeat,
|
||||||
|
defaultStatus,
|
||||||
|
autoResume,
|
||||||
|
instructions,
|
||||||
|
};
|
||||||
|
const isDirty = JSON.stringify(values) !== JSON.stringify(saved);
|
||||||
|
const effectiveModel = useModelOverride
|
||||||
|
? (selectedModel?.name ?? model)
|
||||||
|
: `Default (${currentBackend?.agentName ?? "harness"})`;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setSaved(values);
|
||||||
|
showToast({ body: "Settings saved.", type: "info", uniqueID: "agent-settings-saved" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setAcpAgentId(saved.acpAgentId);
|
||||||
|
setUseModelOverride(saved.useModelOverride);
|
||||||
|
setProvider(saved.provider);
|
||||||
|
setModel(saved.model);
|
||||||
|
setEnvironment(saved.environment);
|
||||||
|
setRepository(saved.repository);
|
||||||
|
setHeartbeat(saved.heartbeat);
|
||||||
|
setDefaultStatus(saved.defaultStatus);
|
||||||
|
setAutoResume(saved.autoResume);
|
||||||
|
setInstructions(saved.instructions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={6} width="100%">
|
||||||
|
<Section variant="muted" padding={5}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={2}>Configuration</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Applied to the next run of {agent.name}.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Grid columns={{ minWidth: 180, repeat: "fit" }} gap={4}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label">Backend</Text>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AcpAgentIcon
|
||||||
|
icon={currentBackend?.icon}
|
||||||
|
name={currentBackend?.agentName ?? ""}
|
||||||
|
size={18}
|
||||||
|
tone="secondary"
|
||||||
|
/>
|
||||||
|
<Text type="body" weight="semibold" maxLines={1}>
|
||||||
|
{currentBackend ? backendLabel(currentBackend) : "Not configured"}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label">Model</Text>
|
||||||
|
<Text type="body" maxLines={1}>
|
||||||
|
{effectiveModel}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label">Repository</Text>
|
||||||
|
<Text type="body" maxLines={1}>
|
||||||
|
{repository}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label">Default status</Text>
|
||||||
|
<Text type="body">{defaultStatus}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 280, repeat: "fit" }} gap={10} width="100%">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>Backend</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ACP harness that executes this agent's tasks.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={4}>
|
||||||
|
{currentBackend ? (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<AcpAgentIcon icon={currentBackend.icon} name={currentBackend.agentName} size={36} />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{currentBackend.agentName}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
v{currentBackend.version} · {currentBackend.agentId}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<Banner
|
||||||
|
status="info"
|
||||||
|
title="No backend configured"
|
||||||
|
description="Choose an ACP harness below to enable runs."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{registry.isLoading ? (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Loading ACP registry…
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : registry.error ? (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{registry.error}
|
||||||
|
</Text>
|
||||||
|
<Button label="Retry" variant="secondary" size="sm" onClick={registry.reload} />
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Selector
|
||||||
|
label="ACP agent"
|
||||||
|
placeholder="Select an agent…"
|
||||||
|
hasSearch
|
||||||
|
searchPlaceholder="Search ACP agents…"
|
||||||
|
value={acpAgentId}
|
||||||
|
onChange={(nextAgentId) => {
|
||||||
|
setAcpAgentId(nextAgentId);
|
||||||
|
setEnvironment(defaultAgentEnvironment(nextAgentId));
|
||||||
|
}}
|
||||||
|
options={registry.agents.filter(hasDistribution).map((acpAgent) => ({
|
||||||
|
value: acpAgent.id,
|
||||||
|
label: `${acpAgent.name} · v${acpAgent.version}`,
|
||||||
|
icon: (
|
||||||
|
<AcpAgentIcon
|
||||||
|
icon={acpAgent.icon}
|
||||||
|
name={acpAgent.name}
|
||||||
|
size={20}
|
||||||
|
tone="secondary"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedAcpAgent ? (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Text type="label">Launch command</Text>
|
||||||
|
<AcpLaunchInstructions agent={selectedAcpAgent} />
|
||||||
|
</VStack>
|
||||||
|
) : null}
|
||||||
|
<TextInput
|
||||||
|
label="Repository"
|
||||||
|
description="Default working repository"
|
||||||
|
value={repository}
|
||||||
|
onChange={setRepository}
|
||||||
|
width="100%"
|
||||||
|
startIcon={<Icon icon={IconBrandGithub} size="sm" />}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 280, repeat: "fit" }} gap={10} width="100%">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>Model</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Optional provider override. Off uses the backend's default model.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Switch
|
||||||
|
label="Override default model"
|
||||||
|
description="Pin a specific provider model"
|
||||||
|
value={useModelOverride}
|
||||||
|
onChange={setUseModelOverride}
|
||||||
|
/>
|
||||||
|
{useModelOverride ? (
|
||||||
|
<Grid columns={{ minWidth: 220, repeat: "fit" }} gap={4}>
|
||||||
|
<Selector
|
||||||
|
label="Provider"
|
||||||
|
value={provider}
|
||||||
|
onChange={(providerId) => {
|
||||||
|
setProvider(providerId);
|
||||||
|
const nextProvider = llmProviders.find(
|
||||||
|
(candidate) => candidate.id === providerId,
|
||||||
|
);
|
||||||
|
setModel(nextProvider?.models[0]?.id ?? "");
|
||||||
|
}}
|
||||||
|
options={providerOptions}
|
||||||
|
/>
|
||||||
|
<Selector label="Model" value={model} onChange={setModel} options={modelOptions} />
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Using {currentBackend?.agentName ?? "the harness"}'s default model.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{useModelOverride && selectedModel ? (
|
||||||
|
<Section variant="muted" padding={3}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" weight="semibold">
|
||||||
|
{selectedLlmProvider.baseUrl}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{selectedModel.limits?.contextWindow?.toLocaleString() ?? "Unknown"} context ·{" "}
|
||||||
|
{selectedModel.pricing?.input !== undefined
|
||||||
|
? `$${selectedModel.pricing.input.toFixed(2)}`
|
||||||
|
: "—"}{" "}
|
||||||
|
input /{" "}
|
||||||
|
{selectedModel.pricing?.output !== undefined
|
||||||
|
? `$${selectedModel.pricing.output.toFixed(2)}`
|
||||||
|
: "—"}{" "}
|
||||||
|
output per 1M tokens
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 280, repeat: "fit" }} gap={10} width="100%">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>Environment</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Values injected into the agent process when it launches.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<HStack hAlign="between" vAlign="center" gap={3} wrap="wrap">
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Provider keys are resolved securely and remain masked in previews.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label="Add variable"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon icon={IconPlus} size="sm" />}
|
||||||
|
onClick={() =>
|
||||||
|
setEnvironment((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
name: `ENV_VARIABLE_${current.length + 1}`,
|
||||||
|
value: { source: "literal", value: "" },
|
||||||
|
origin: "agent-override",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
{environment.length ? (
|
||||||
|
<VStack gap={3}>
|
||||||
|
{environment.map((variable, index) => (
|
||||||
|
<Section key={`${variable.name}-${index}`} variant="muted" padding={3}>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<Grid columns={{ minWidth: 190, max: 2 }} gap={3}>
|
||||||
|
<TextInput
|
||||||
|
label="Variable name"
|
||||||
|
value={variable.name}
|
||||||
|
onChange={(name) =>
|
||||||
|
setEnvironment((current) =>
|
||||||
|
current.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, name, origin: "agent-override" }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
label="Injected value"
|
||||||
|
value={variable.value.source}
|
||||||
|
options={ENVIRONMENT_SOURCE_OPTIONS}
|
||||||
|
onChange={(source) => {
|
||||||
|
const value: AgentEnvironmentValue =
|
||||||
|
source === "literal"
|
||||||
|
? { source: "literal", value: "" }
|
||||||
|
: {
|
||||||
|
source: source as
|
||||||
|
| "provider-api-key"
|
||||||
|
| "provider-base-url"
|
||||||
|
| "selected-model",
|
||||||
|
};
|
||||||
|
setEnvironment((current) =>
|
||||||
|
current.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, value, origin: "agent-override" }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{variable.value.source === "literal" ? (
|
||||||
|
<TextInput
|
||||||
|
label="Literal value"
|
||||||
|
value={variable.value.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEnvironment((current) =>
|
||||||
|
current.map((entry, entryIndex) =>
|
||||||
|
entryIndex === index
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
value: { source: "literal", value },
|
||||||
|
origin: "agent-override",
|
||||||
|
}
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<HStack hAlign="between" vAlign="center" gap={2}>
|
||||||
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
|
<Badge
|
||||||
|
label={
|
||||||
|
variable.origin === "backend-default"
|
||||||
|
? "Backend default"
|
||||||
|
: "Agent override"
|
||||||
|
}
|
||||||
|
variant={variable.origin === "backend-default" ? "neutral" : "blue"}
|
||||||
|
/>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Preview:{" "}
|
||||||
|
{variable.value.source === "provider-api-key"
|
||||||
|
? "••••••••••••"
|
||||||
|
: variable.value.source === "provider-base-url"
|
||||||
|
? selectedLlmProvider.baseUrl
|
||||||
|
: variable.value.source === "selected-model"
|
||||||
|
? (selectedModel?.id ?? "No model selected")
|
||||||
|
: environmentValueLabel(variable.value)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
label={`Remove ${variable.name}`}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
icon={<Icon icon={IconTrash} size="sm" />}
|
||||||
|
onClick={() =>
|
||||||
|
setEnvironment((current) =>
|
||||||
|
current.filter((_, entryIndex) => entryIndex !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Section variant="muted" padding={3}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
This backend has no default environment mapping. Add the variables its launcher
|
||||||
|
expects.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 280, repeat: "fit" }} gap={10} width="100%">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>Runtime</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Execution and health-check behavior.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Grid columns={{ minWidth: 220, repeat: "fit" }} gap={4}>
|
||||||
|
<Selector
|
||||||
|
label="Heartbeat interval"
|
||||||
|
value={heartbeat}
|
||||||
|
onChange={setHeartbeat}
|
||||||
|
options={HEARTBEAT_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
label="Default status"
|
||||||
|
value={defaultStatus}
|
||||||
|
onChange={setDefaultStatus}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Switch
|
||||||
|
label="Auto-resume after failure"
|
||||||
|
description="Restart automatically when a run fails"
|
||||||
|
value={autoResume}
|
||||||
|
onChange={setAutoResume}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 280, repeat: "fit" }} gap={10} width="100%">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={3}>Instructions</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Guidance applied to every run started by this agent.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<TextArea
|
||||||
|
label="Instructions"
|
||||||
|
description="System prompt prepended to every run."
|
||||||
|
value={instructions}
|
||||||
|
onChange={setInstructions}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<HStack hAlign="between" vAlign="center" gap={4} wrap="wrap">
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{isDirty ? "You have unsaved changes." : "All changes saved."}
|
||||||
|
</Text>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button label="Reset" variant="secondary" isDisabled={!isDirty} onClick={handleReset} />
|
||||||
|
<Button
|
||||||
|
label="Save changes"
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={!isDirty}
|
||||||
|
onClick={handleSave}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutFooter,
|
||||||
|
StackItem,
|
||||||
|
VStack,
|
||||||
|
} from "@astryxdesign/core/Layout";
|
||||||
|
import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl";
|
||||||
|
import { Selector } from "@astryxdesign/core/Selector";
|
||||||
|
import { Spinner } from "@astryxdesign/core/Spinner";
|
||||||
|
import { Switch } from "@astryxdesign/core/Switch";
|
||||||
|
import { Table } from "@astryxdesign/core/Table";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { IconLayoutGrid, IconList, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { AcpAgentIcon } from "../components/AcpAgentIcon";
|
||||||
|
import { backendLabel, modelLabel } from "../lib/backend-label";
|
||||||
|
import { hasDistribution } from "../lib/launch";
|
||||||
|
import { useAcpRegistry } from "../lib/registry";
|
||||||
|
import type { AcpBackend, AgentModelOverride } from "../lib/types";
|
||||||
|
import { CONFIGURED_LLM_PROVIDERS, defaultAgentEnvironment } from "../../llm-providers/lib/data";
|
||||||
|
import { useLlmProviders } from "../../llm-providers/lib/store";
|
||||||
|
import { useFilteredList } from "../../../hooks/use-filtered-list";
|
||||||
|
import { PageHero } from "../../../layouts/PageHero";
|
||||||
|
import { agentRows, statusLabel, type AgentFilter, type AgentView } from "../lib/agent-rows";
|
||||||
|
import { AgentCard } from "../components/AgentCard";
|
||||||
|
import { columns } from "../components/agentColumns";
|
||||||
|
|
||||||
|
export function AgentsPage() {
|
||||||
|
const [view, setView] = useState<AgentView>("cards");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<AgentFilter>("all");
|
||||||
|
const [agents, setAgents] = useState(agentRows);
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [newAgentName, setNewAgentName] = useState("");
|
||||||
|
const [newAgentDescription, setNewAgentDescription] = useState("");
|
||||||
|
const [acpAgentId, setAcpAgentId] = useState("");
|
||||||
|
const [useModelOverride, setUseModelOverride] = useState(false);
|
||||||
|
const [overrideProvider, setOverrideProvider] = useState("anthropic-production");
|
||||||
|
const [overrideModel, setOverrideModel] = useState("claude-sonnet-4");
|
||||||
|
const registry = useAcpRegistry();
|
||||||
|
const llmProviders = useLlmProviders();
|
||||||
|
const selectedLlmProvider =
|
||||||
|
llmProviders.find((provider) => provider.id === overrideProvider) ??
|
||||||
|
llmProviders[0] ??
|
||||||
|
CONFIGURED_LLM_PROVIDERS[0]!;
|
||||||
|
const providerOptions = llmProviders
|
||||||
|
.filter((provider) => provider.enabled)
|
||||||
|
.map((provider) => ({ label: provider.name, value: provider.id }));
|
||||||
|
const modelOptions = selectedLlmProvider.models
|
||||||
|
.filter((model) => model.enabled && model.availability === "available")
|
||||||
|
.map((model) => ({ label: model.name, value: model.id }));
|
||||||
|
|
||||||
|
const {
|
||||||
|
query: search,
|
||||||
|
setQuery: setSearch,
|
||||||
|
filtered: filteredAgents,
|
||||||
|
} = useFilteredList(agents, {
|
||||||
|
searchFields: (agent) => [
|
||||||
|
agent.name,
|
||||||
|
agent.description,
|
||||||
|
backendLabel(agent.backend),
|
||||||
|
modelLabel(agent.modelOverride),
|
||||||
|
statusLabel[agent.status],
|
||||||
|
],
|
||||||
|
predicate: (agent) =>
|
||||||
|
statusFilter === "all" || (statusFilter === "active" && agent.status === "active"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAgent = () => {
|
||||||
|
const name = newAgentName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const acpAgent = registry.agents.find((candidate) => candidate.id === acpAgentId);
|
||||||
|
if (!acpAgent) return;
|
||||||
|
|
||||||
|
const backend: AcpBackend = {
|
||||||
|
agentId: acpAgent.id,
|
||||||
|
agentName: acpAgent.name,
|
||||||
|
version: acpAgent.version,
|
||||||
|
icon: acpAgent.icon,
|
||||||
|
};
|
||||||
|
const selectedModel = selectedLlmProvider.models.find((model) => model.id === overrideModel);
|
||||||
|
const modelOverride: AgentModelOverride | undefined =
|
||||||
|
useModelOverride && selectedModel
|
||||||
|
? {
|
||||||
|
providerId: selectedLlmProvider.id,
|
||||||
|
providerName: selectedLlmProvider.name,
|
||||||
|
modelId: selectedModel.id,
|
||||||
|
modelName: selectedModel.name,
|
||||||
|
environment: defaultAgentEnvironment(acpAgent.id),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
setAgents((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${current.length + 1}`,
|
||||||
|
name,
|
||||||
|
description: newAgentDescription.trim() || "A new agent ready to configure.",
|
||||||
|
status: "draft",
|
||||||
|
backend,
|
||||||
|
modelOverride,
|
||||||
|
activeTasks: 0,
|
||||||
|
activeTaskTitles: [],
|
||||||
|
completedTasks: 0,
|
||||||
|
lastHeartbeat: "Never",
|
||||||
|
activity: [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setNewAgentName("");
|
||||||
|
setNewAgentDescription("");
|
||||||
|
setAcpAgentId("");
|
||||||
|
setUseModelOverride(false);
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = (
|
||||||
|
<LayoutContent padding={6}>
|
||||||
|
<VStack gap={5} width="100%">
|
||||||
|
<PageHero
|
||||||
|
eyebrow="Workspace"
|
||||||
|
title="Agents"
|
||||||
|
description="Monitor and manage the agents running work for your organization."
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
label="Create agent"
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon icon={IconPlus} size="sm" />}
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||||
|
<SegmentedControl
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(nextFilter) => setStatusFilter(nextFilter as AgentFilter)}
|
||||||
|
label="Agent status"
|
||||||
|
>
|
||||||
|
<SegmentedControlItem value="all" label="All" />
|
||||||
|
<SegmentedControlItem value="active" label="Active" />
|
||||||
|
</SegmentedControl>
|
||||||
|
<StackItem size="fill">
|
||||||
|
<TextInput
|
||||||
|
label="Search agents"
|
||||||
|
isLabelHidden
|
||||||
|
placeholder="Search agents…"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
startIcon={IconSearch}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={(nextView) => setView(nextView as AgentView)}
|
||||||
|
label="Agent view"
|
||||||
|
>
|
||||||
|
<SegmentedControlItem
|
||||||
|
value="cards"
|
||||||
|
label="Cards"
|
||||||
|
icon={<Icon icon={IconLayoutGrid} size="sm" />}
|
||||||
|
/>
|
||||||
|
<SegmentedControlItem
|
||||||
|
value="table"
|
||||||
|
label="Table"
|
||||||
|
icon={<Icon icon={IconList} size="sm" />}
|
||||||
|
/>
|
||||||
|
</SegmentedControl>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{filteredAgents.length} agent{filteredAgents.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{view === "cards" ? (
|
||||||
|
<Grid columns={{ minWidth: 360, max: 3 }} gap={4} align="stretch">
|
||||||
|
{filteredAgents.map((agent) => (
|
||||||
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Card padding={0}>
|
||||||
|
<Table
|
||||||
|
data={filteredAgents}
|
||||||
|
columns={columns}
|
||||||
|
idKey="id"
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
verticalAlign="middle"
|
||||||
|
textOverflow="truncate"
|
||||||
|
hasHover
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredAgents.length === 0 ? (
|
||||||
|
<Card padding={6}>
|
||||||
|
<VStack gap={2} hAlign="center">
|
||||||
|
<Icon icon={IconSearch} size="lg" color="secondary" />
|
||||||
|
<Heading level={2}>No agents found</Heading>
|
||||||
|
<Button
|
||||||
|
label="Clear filters"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setStatusFilter("all");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout height="fill" content={page} />
|
||||||
|
|
||||||
|
<Dialog isOpen={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<Layout
|
||||||
|
header={<DialogHeader title="Create agent" onOpenChange={setIsCreateOpen} />}
|
||||||
|
content={
|
||||||
|
<LayoutContent padding={4}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Pull Request Reviewer"
|
||||||
|
value={newAgentName}
|
||||||
|
onChange={setNewAgentName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Description"
|
||||||
|
placeholder="What should this agent do?"
|
||||||
|
value={newAgentDescription}
|
||||||
|
onChange={setNewAgentDescription}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Text type="label">Backend</Text>
|
||||||
|
{registry.isLoading ? (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Loading ACP registry…
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : registry.error ? (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{registry.error}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label="Retry"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={registry.reload}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Selector
|
||||||
|
label="Backend"
|
||||||
|
description="ACP harness that runs this agent's tasks"
|
||||||
|
placeholder="Select an agent…"
|
||||||
|
hasSearch
|
||||||
|
searchPlaceholder="Search ACP agents…"
|
||||||
|
value={acpAgentId}
|
||||||
|
onChange={setAcpAgentId}
|
||||||
|
options={registry.agents.filter(hasDistribution).map((acpAgent) => ({
|
||||||
|
value: acpAgent.id,
|
||||||
|
label: `${acpAgent.name} · v${acpAgent.version}`,
|
||||||
|
icon: (
|
||||||
|
<AcpAgentIcon
|
||||||
|
icon={acpAgent.icon}
|
||||||
|
name={acpAgent.name}
|
||||||
|
size={20}
|
||||||
|
tone="secondary"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Switch
|
||||||
|
label="Override default model"
|
||||||
|
description="Pin a specific provider model; off uses the backend's default model"
|
||||||
|
value={useModelOverride}
|
||||||
|
onChange={setUseModelOverride}
|
||||||
|
/>
|
||||||
|
{useModelOverride ? (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Selector
|
||||||
|
label="Provider"
|
||||||
|
value={overrideProvider}
|
||||||
|
onChange={(providerId) => {
|
||||||
|
setOverrideProvider(providerId);
|
||||||
|
const nextProvider = llmProviders.find(
|
||||||
|
(provider) => provider.id === providerId,
|
||||||
|
);
|
||||||
|
setOverrideModel(nextProvider?.models[0]?.id ?? "");
|
||||||
|
}}
|
||||||
|
options={providerOptions}
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
label="Model"
|
||||||
|
value={overrideModel}
|
||||||
|
onChange={setOverrideModel}
|
||||||
|
options={modelOptions}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<LayoutFooter hasDivider>
|
||||||
|
<HStack gap={2} hAlign="end">
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={() => setIsCreateOpen(false)} />
|
||||||
|
<Button
|
||||||
|
label="Create agent"
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={!newAgentName.trim() || !acpAgentId}
|
||||||
|
onClick={createAgent}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</LayoutFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Selector } from "@astryxdesign/core/Selector";
|
||||||
|
import { proportional, pixel, Table, type TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
ACCESS_OPTIONS,
|
||||||
|
ROLE_BADGE_VARIANT,
|
||||||
|
type AccessLevel,
|
||||||
|
type AccessMember,
|
||||||
|
} from "../lib/agent-details";
|
||||||
|
|
||||||
|
export function Permissions() {
|
||||||
|
const [access, setAccess] = useState<Record<string, AccessLevel>>({
|
||||||
|
omkar: "manage",
|
||||||
|
maya: "manage",
|
||||||
|
jordan: "use",
|
||||||
|
});
|
||||||
|
const members: AccessMember[] = [
|
||||||
|
{
|
||||||
|
id: "omkar",
|
||||||
|
name: "Omkar",
|
||||||
|
email: "[email protected]",
|
||||||
|
role: "Owner",
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
{ id: "maya", name: "Maya Chen", email: "[email protected]", role: "Administrator" },
|
||||||
|
{ id: "jordan", name: "Jordan Lee", email: "[email protected]", role: "Member" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: TableColumn<AccessMember>[] = [
|
||||||
|
{
|
||||||
|
key: "member",
|
||||||
|
header: "Member",
|
||||||
|
width: proportional(2),
|
||||||
|
renderCell: (member) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Avatar size="sm" name={member.name} alt={member.name} />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" size="sm" weight="bold">
|
||||||
|
{member.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" size="sm" color="secondary">
|
||||||
|
{member.email}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "role",
|
||||||
|
header: "Role",
|
||||||
|
width: pixel(140),
|
||||||
|
renderCell: (member) => (
|
||||||
|
<Badge variant={ROLE_BADGE_VARIANT[member.role]} label={member.role} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "access",
|
||||||
|
header: "Access",
|
||||||
|
width: pixel(190),
|
||||||
|
renderCell: (member) => (
|
||||||
|
<Selector
|
||||||
|
label={`Access for ${member.name}`}
|
||||||
|
isLabelHidden
|
||||||
|
size="sm"
|
||||||
|
value={access[member.id]}
|
||||||
|
options={ACCESS_OPTIONS}
|
||||||
|
isDisabled={member.locked}
|
||||||
|
onChange={(value) =>
|
||||||
|
setAccess((current) => ({ ...current, [member.id]: value as AccessLevel }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={8} width="100%">
|
||||||
|
<HStack hAlign="between" vAlign="end" wrap="wrap" gap={5}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={2}>Team access</Heading>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
Choose who in your organization can view, use, or manage this agent.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Button label="Add member" variant="primary" icon={<Icon icon={IconPlus} size="sm" />} />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack gap={4} width="100%">
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Heading level={3}>Members</Heading>
|
||||||
|
<Badge variant="neutral" label={String(members.length)} />
|
||||||
|
</HStack>
|
||||||
|
<Table
|
||||||
|
data={members}
|
||||||
|
columns={columns}
|
||||||
|
idKey="id"
|
||||||
|
density="balanced"
|
||||||
|
hasHover
|
||||||
|
dividers="rows"
|
||||||
|
verticalAlign="middle"
|
||||||
|
textOverflow="truncate"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
defaultAgentEnvironment,
|
||||||
|
type AgentEnvironmentVariable,
|
||||||
|
} from "../../llm-providers/lib/data";
|
||||||
|
import type { AgentRow } from "./agent-rows";
|
||||||
|
|
||||||
|
export type AccessLevel = "manage" | "use" | "view" | "none";
|
||||||
|
|
||||||
|
/** Detail page sections for an agent. */
|
||||||
|
export type AgentSection = "overview" | "tasks" | "permissions" | "settings";
|
||||||
|
|
||||||
|
export interface AccessMember extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "Owner" | "Administrator" | "Member";
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACCESS_OPTIONS: { value: AccessLevel; label: string }[] = [
|
||||||
|
{ value: "manage", label: "Can manage" },
|
||||||
|
{ value: "use", label: "Can use" },
|
||||||
|
{ value: "view", label: "Can view" },
|
||||||
|
{ value: "none", label: "No access" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROLE_BADGE_VARIANT: Record<AccessMember["role"], "blue" | "neutral"> = {
|
||||||
|
Owner: "blue",
|
||||||
|
Administrator: "blue",
|
||||||
|
Member: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENVIRONMENT_SOURCE_OPTIONS = [
|
||||||
|
{ label: "Provider API key", value: "provider-api-key" },
|
||||||
|
{ label: "Provider base URL", value: "provider-base-url" },
|
||||||
|
{ label: "Selected model", value: "selected-model" },
|
||||||
|
{ label: "Literal value", value: "literal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HEARTBEAT_OPTIONS = [
|
||||||
|
{ label: "Every 30 seconds", value: "Every 30 seconds" },
|
||||||
|
{ label: "Every minute", value: "Every minute" },
|
||||||
|
{ label: "Every 5 minutes", value: "Every 5 minutes" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS = [
|
||||||
|
{ label: "Active", value: "Active" },
|
||||||
|
{ label: "Idle", value: "Idle" },
|
||||||
|
{ label: "Draft", value: "Draft" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SettingsValues {
|
||||||
|
acpAgentId: string;
|
||||||
|
useModelOverride: boolean;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
environment: AgentEnvironmentVariable[];
|
||||||
|
repository: string;
|
||||||
|
heartbeat: string;
|
||||||
|
defaultStatus: string;
|
||||||
|
autoResume: boolean;
|
||||||
|
instructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialSettingsValues(agent: AgentRow): SettingsValues {
|
||||||
|
return {
|
||||||
|
acpAgentId: agent.backend?.agentId ?? "",
|
||||||
|
useModelOverride: Boolean(agent.modelOverride),
|
||||||
|
provider: agent.modelOverride?.providerId ?? "anthropic-production",
|
||||||
|
model: agent.modelOverride?.modelId ?? "claude-sonnet-4",
|
||||||
|
environment:
|
||||||
|
agent.modelOverride?.environment ?? defaultAgentEnvironment(agent.backend?.agentId ?? ""),
|
||||||
|
repository: "anthaathi/attendance",
|
||||||
|
heartbeat: "Every 30 seconds",
|
||||||
|
defaultStatus: "Active",
|
||||||
|
autoResume: true,
|
||||||
|
instructions: `${agent.description} Work independently, keep changes focused, report blockers early, and include evidence with every completed task.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { spacingVars } from "@astryxdesign/core/theme/tokens.stylex";
|
||||||
|
import type { AcpBackend, AgentModelOverride } from "./types";
|
||||||
|
import { defaultAgentEnvironment } from "../../llm-providers/lib/data";
|
||||||
|
|
||||||
|
export type AgentStatus = "active" | "idle" | "draft";
|
||||||
|
export type AgentView = "cards" | "table";
|
||||||
|
export type AgentFilter = "all" | "active";
|
||||||
|
|
||||||
|
export interface AgentRow extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
/** ACP harness from the registry. Undefined for unconfigured drafts. */
|
||||||
|
backend?: AcpBackend;
|
||||||
|
/** Optional model override — when absent the backend uses its default model. */
|
||||||
|
modelOverride?: AgentModelOverride;
|
||||||
|
activeTasks: number;
|
||||||
|
activeTaskTitles: string[];
|
||||||
|
completedTasks: number;
|
||||||
|
lastHeartbeat: string;
|
||||||
|
activity: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTRY_ICON = (id: string) =>
|
||||||
|
`https://cdn.agentclientprotocol.com/registry/v1/latest/${id}.svg`;
|
||||||
|
|
||||||
|
export const agentRows: AgentRow[] = [
|
||||||
|
{
|
||||||
|
id: "code-reviewer",
|
||||||
|
name: "Code Reviewer",
|
||||||
|
description: "Reviews pull requests and recommends focused code changes.",
|
||||||
|
imageUrl: "/agents/code-reviewer.png",
|
||||||
|
status: "active",
|
||||||
|
backend: {
|
||||||
|
agentId: "codex-acp",
|
||||||
|
agentName: "Codex",
|
||||||
|
version: "1.1.14",
|
||||||
|
icon: REGISTRY_ICON("codex-acp"),
|
||||||
|
},
|
||||||
|
modelOverride: {
|
||||||
|
providerId: "openai-production",
|
||||||
|
providerName: "OpenAI Production",
|
||||||
|
modelId: "gpt-5.2",
|
||||||
|
modelName: "GPT-5.2",
|
||||||
|
environment: defaultAgentEnvironment("codex-acp"),
|
||||||
|
},
|
||||||
|
activeTasks: 3,
|
||||||
|
activeTaskTitles: [
|
||||||
|
"Review authentication middleware",
|
||||||
|
"Check invitation flow regressions",
|
||||||
|
"Audit task table changes",
|
||||||
|
],
|
||||||
|
completedTasks: 42,
|
||||||
|
lastHeartbeat: "12 seconds ago",
|
||||||
|
activity: [18, 28, 42, 36, 54, 68, 52, 76, 64, 82, 72, 88],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "research-scout",
|
||||||
|
name: "Research Scout",
|
||||||
|
description: "Researches technical questions and returns sourced findings.",
|
||||||
|
imageUrl: "/agents/research-scout.png",
|
||||||
|
status: "idle",
|
||||||
|
backend: {
|
||||||
|
agentId: "gemini",
|
||||||
|
agentName: "Gemini CLI",
|
||||||
|
version: "0.54.4",
|
||||||
|
icon: REGISTRY_ICON("gemini"),
|
||||||
|
},
|
||||||
|
activeTasks: 0,
|
||||||
|
activeTaskTitles: [],
|
||||||
|
completedTasks: 18,
|
||||||
|
lastHeartbeat: "8 minutes ago",
|
||||||
|
activity: [12, 18, 14, 22, 18, 26, 20, 16, 24, 18, 14, 12],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-triage",
|
||||||
|
name: "Issue Triage",
|
||||||
|
description: "Classifies incoming issues and recommends ownership and priority.",
|
||||||
|
imageUrl: "/agents/issue-triage.png",
|
||||||
|
status: "active",
|
||||||
|
backend: {
|
||||||
|
agentId: "claude-acp",
|
||||||
|
agentName: "Claude Agent",
|
||||||
|
version: "0.66.0",
|
||||||
|
icon: REGISTRY_ICON("claude-acp"),
|
||||||
|
},
|
||||||
|
modelOverride: {
|
||||||
|
providerId: "anthropic-production",
|
||||||
|
providerName: "Anthropic Production",
|
||||||
|
modelId: "claude-sonnet-4",
|
||||||
|
modelName: "Claude Sonnet 4",
|
||||||
|
environment: defaultAgentEnvironment("claude-acp"),
|
||||||
|
},
|
||||||
|
activeTasks: 7,
|
||||||
|
activeTaskTitles: [
|
||||||
|
"Triage provider connection failure",
|
||||||
|
"Classify repository sync issue",
|
||||||
|
"Review chatbot integration request",
|
||||||
|
"Prioritize agent heartbeat alert",
|
||||||
|
"Assign deployment regression",
|
||||||
|
"Label OAuth callback issue",
|
||||||
|
"Route task import bug",
|
||||||
|
],
|
||||||
|
completedTasks: 67,
|
||||||
|
lastHeartbeat: "5 seconds ago",
|
||||||
|
activity: [32, 48, 44, 62, 58, 78, 68, 86, 72, 92, 84, 96],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "release-manager",
|
||||||
|
name: "Release Manager",
|
||||||
|
description: "Coordinates release readiness, changelogs, and deployment checks.",
|
||||||
|
imageUrl: "/agents/release-manager.png",
|
||||||
|
status: "idle",
|
||||||
|
backend: {
|
||||||
|
agentId: "goose",
|
||||||
|
agentName: "goose",
|
||||||
|
version: "1.45.0",
|
||||||
|
icon: REGISTRY_ICON("goose"),
|
||||||
|
},
|
||||||
|
activeTasks: 0,
|
||||||
|
activeTaskTitles: [],
|
||||||
|
completedTasks: 24,
|
||||||
|
lastHeartbeat: "2 hours ago",
|
||||||
|
activity: [28, 34, 30, 40, 32, 36, 28, 24, 20, 18, 14, 12],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-auditor",
|
||||||
|
name: "Security Auditor",
|
||||||
|
description: "Reviews sensitive changes and vulnerable dependencies.",
|
||||||
|
imageUrl: "/agents/security-auditor.png",
|
||||||
|
status: "draft",
|
||||||
|
activeTasks: 0,
|
||||||
|
activeTaskTitles: [],
|
||||||
|
completedTasks: 0,
|
||||||
|
lastHeartbeat: "Never",
|
||||||
|
activity: [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs-writer",
|
||||||
|
name: "Documentation Writer",
|
||||||
|
description: "Turns implementation changes into clear product documentation.",
|
||||||
|
imageUrl: "/agents/docs-writer.png",
|
||||||
|
status: "active",
|
||||||
|
backend: {
|
||||||
|
agentId: "opencode",
|
||||||
|
agentName: "OpenCode",
|
||||||
|
version: "1.18.15",
|
||||||
|
icon: REGISTRY_ICON("opencode"),
|
||||||
|
},
|
||||||
|
modelOverride: {
|
||||||
|
providerId: "google-gemini",
|
||||||
|
providerName: "Google Gemini",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
|
modelName: "Gemini 2.5 Pro",
|
||||||
|
environment: defaultAgentEnvironment("opencode"),
|
||||||
|
},
|
||||||
|
activeTasks: 2,
|
||||||
|
activeTaskTitles: ["Document agent configuration", "Update repository setup guide"],
|
||||||
|
completedTasks: 31,
|
||||||
|
lastHeartbeat: "30 seconds ago",
|
||||||
|
activity: [18, 24, 32, 28, 46, 40, 58, 52, 64, 58, 72, 68],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const statusLabel: Record<AgentStatus, string> = {
|
||||||
|
active: "Active",
|
||||||
|
idle: "Idle",
|
||||||
|
draft: "Draft",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusDotVariant: Record<AgentStatus, "success" | "neutral"> = {
|
||||||
|
active: "success",
|
||||||
|
idle: "neutral",
|
||||||
|
draft: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const styles = stylex.create({
|
||||||
|
agentCard: {
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
activityPanel: {
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
overflow: "hidden",
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: `calc(${spacingVars["--spacing-12"]} + ${spacingVars["--spacing-6"]})`,
|
||||||
|
},
|
||||||
|
activityChart: {
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
activityContent: {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
metricTrigger: {
|
||||||
|
width: "fit-content",
|
||||||
|
paddingInline: 0,
|
||||||
|
paddingBlock: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { AcpBackend, AgentModelOverride } from "./types";
|
||||||
|
|
||||||
|
/** Short display label for an agent's ACP backend, e.g. "Codex · v1.1.14". */
|
||||||
|
export function backendLabel(backend?: AcpBackend): string {
|
||||||
|
return backend ? `${backend.agentName} · v${backend.version}` : "No backend configured";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display label for the optional model override. When no override is set the
|
||||||
|
* backend harness uses its own default model.
|
||||||
|
*/
|
||||||
|
export function modelLabel(modelOverride?: AgentModelOverride): string {
|
||||||
|
return modelOverride ? modelOverride.modelName : "Default harness model";
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
binaryPlatformsLabel,
|
||||||
|
buildLaunchCommand,
|
||||||
|
distributionKind,
|
||||||
|
firstBinaryTarget,
|
||||||
|
hasDistribution,
|
||||||
|
type PlatformInfo,
|
||||||
|
} from "./launch";
|
||||||
|
import type { AcpAgent } from "./types";
|
||||||
|
|
||||||
|
const linuxX64: PlatformInfo = { platform: "linux-x86_64", label: "Linux (x64)" };
|
||||||
|
const windowsX64: PlatformInfo = { platform: "windows-x86_64", label: "Windows (x64)" };
|
||||||
|
|
||||||
|
function agent(overrides: Partial<AcpAgent>): AcpAgent {
|
||||||
|
return { id: "test-agent", name: "Test Agent", version: "1.0.0", ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("distributionKind", () => {
|
||||||
|
it("prefers npx over binary when both are shipped", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
npx: { package: "[email protected]" },
|
||||||
|
binary: {
|
||||||
|
"linux-x86_64": { archive: "https://example.com/a.tar.gz", cmd: "./agent" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(distributionKind(a)).toBe("npx");
|
||||||
|
expect(hasDistribution(a)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when an agent has no distribution", () => {
|
||||||
|
expect(distributionKind(agent({}))).toBeNull();
|
||||||
|
expect(hasDistribution(agent({}))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildLaunchCommand", () => {
|
||||||
|
it("builds an npx command with versioned package", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: { npx: { package: "@org/[email protected]" } },
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, null);
|
||||||
|
expect(launch?.script).toBe("npx @org/[email protected]");
|
||||||
|
expect(launch?.kind).toBe("npx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends args and env to npx commands", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
npx: {
|
||||||
|
package: "[email protected]",
|
||||||
|
args: ["--acp", "--verbose"],
|
||||||
|
env: { FOO: "bar baz", AUGMENT_DISABLE_AUTO_UPDATE: "1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, null);
|
||||||
|
expect(launch?.script).toBe(
|
||||||
|
"FOO='bar baz' AUGMENT_DISABLE_AUTO_UPDATE=1 npx [email protected] --acp --verbose",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a uvx command when only uvx is shipped", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: { uvx: { package: "[email protected]" } },
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, null);
|
||||||
|
expect(launch?.script).toBe("uvx [email protected]");
|
||||||
|
expect(launch?.kind).toBe("uvx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds install + run steps for a native binary release", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
binary: {
|
||||||
|
"linux-x86_64": {
|
||||||
|
archive: "https://example.com/agent-linux-x86_64.tar.gz",
|
||||||
|
cmd: "./agent",
|
||||||
|
args: ["acp"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, linuxX64);
|
||||||
|
expect(launch?.kind).toBe("binary");
|
||||||
|
expect(launch?.script).toContain('curl -fsSL "https://example.com/agent-linux-x86_64.tar.gz"');
|
||||||
|
expect(launch?.script).toContain("tar -xf");
|
||||||
|
expect(launch?.script).toContain("~/.acp-agents/test-agent/./agent acp");
|
||||||
|
expect(launch?.note).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses unzip for zip archives", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
binary: {
|
||||||
|
"windows-x86_64": {
|
||||||
|
archive: "https://example.com/agent-win-x64.zip",
|
||||||
|
cmd: "agent.exe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, windowsX64);
|
||||||
|
expect(launch?.script).toContain("unzip -q");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a run-only command with a note when the platform has no release", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
binary: {
|
||||||
|
"darwin-aarch64": {
|
||||||
|
archive: "https://example.com/agent-mac.tar.gz",
|
||||||
|
cmd: "./agent",
|
||||||
|
args: ["acp"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, linuxX64);
|
||||||
|
expect(launch?.kind).toBe("binary");
|
||||||
|
expect(launch?.script).toBe("./agent acp");
|
||||||
|
expect(launch?.note).toContain("No release for your platform");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows the run command when the platform cannot be detected", () => {
|
||||||
|
const a = agent({
|
||||||
|
distribution: {
|
||||||
|
binary: {
|
||||||
|
"darwin-aarch64": { archive: "https://example.com/agent-mac.tar.gz", cmd: "./agent" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const launch = buildLaunchCommand(a, null);
|
||||||
|
expect(launch?.script).toBe("./agent");
|
||||||
|
expect(launch?.note).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for agents without any distribution", () => {
|
||||||
|
expect(buildLaunchCommand(agent({}), null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("binary helpers", () => {
|
||||||
|
it("lists platforms in stable order", () => {
|
||||||
|
const binary = {
|
||||||
|
"darwin-aarch64": { archive: "a", cmd: "./a" },
|
||||||
|
"linux-x86_64": { archive: "b", cmd: "./b" },
|
||||||
|
};
|
||||||
|
expect(binaryPlatformsLabel(binary)).toBe("darwin-aarch64 · linux-x86_64");
|
||||||
|
expect(firstBinaryTarget(binary)?.platform).toBe("darwin-aarch64");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { AcpAgent, AcpBinaryTarget, AcpDistribution, AcpPlatform } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an ACP agent is launched. `npx` and `uvx` are cross-platform package
|
||||||
|
* runners; `binary` means per-platform release archives that must be
|
||||||
|
* downloaded and extracted first.
|
||||||
|
*/
|
||||||
|
export type DistributionKind = "npx" | "uvx" | "binary";
|
||||||
|
|
||||||
|
/** Distribution kinds, ordered by preference when an agent ships more than one. */
|
||||||
|
const DISTRIBUTION_PREFERENCE: DistributionKind[] = ["npx", "uvx", "binary"];
|
||||||
|
|
||||||
|
export const distributionKindLabel: Record<DistributionKind, string> = {
|
||||||
|
npx: "npx",
|
||||||
|
uvx: "uvx",
|
||||||
|
binary: "Binary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function distributionKind(agent: AcpAgent): DistributionKind | null {
|
||||||
|
const dist = agent.distribution;
|
||||||
|
if (!dist) return null;
|
||||||
|
return DISTRIBUTION_PREFERENCE.find((candidate) => Boolean(dist[candidate])) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDistribution(agent: AcpAgent): boolean {
|
||||||
|
return distributionKind(agent) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInfo {
|
||||||
|
platform: AcpPlatform;
|
||||||
|
/** Human-readable label, e.g. "macOS (Apple Silicon)". */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the user's OS + architecture from the browser. Uses the high-entropy
|
||||||
|
* User-Agent Client Hints API when available and falls back to the legacy UA
|
||||||
|
* string. Returns null when the platform cannot be determined.
|
||||||
|
*/
|
||||||
|
export function detectPlatform(): PlatformInfo | null {
|
||||||
|
if (typeof navigator === "undefined") return null;
|
||||||
|
const ua = navigator.userAgent ?? "";
|
||||||
|
const highEntropy = (
|
||||||
|
navigator as Navigator & {
|
||||||
|
userAgentData?: { platform?: string; architecture?: string };
|
||||||
|
}
|
||||||
|
).userAgentData;
|
||||||
|
const platform = (highEntropy?.platform ?? "").toLowerCase();
|
||||||
|
const isWindows = platform.includes("win") || /windows/i.test(ua);
|
||||||
|
const isMac = platform.includes("mac") || /mac os x|macintosh/i.test(ua);
|
||||||
|
const isLinux = platform.includes("linux") || /linux/i.test(ua);
|
||||||
|
const arch = (highEntropy?.architecture ?? "").toLowerCase();
|
||||||
|
const isArm = arch === "arm" || /arm64|aarch64/i.test(ua);
|
||||||
|
|
||||||
|
if (isWindows) return { platform: "windows-x86_64", label: "Windows (x64)" };
|
||||||
|
if (isMac) {
|
||||||
|
return isArm
|
||||||
|
? { platform: "darwin-aarch64", label: "macOS (Apple Silicon)" }
|
||||||
|
: { platform: "darwin-x86_64", label: "macOS (Intel)" };
|
||||||
|
}
|
||||||
|
if (isLinux) {
|
||||||
|
return isArm
|
||||||
|
? { platform: "linux-aarch64", label: "Linux (ARM64)" }
|
||||||
|
: { platform: "linux-x86_64", label: "Linux (x64)" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ready-to-paste shell snippet that installs (when needed) and launches an
|
||||||
|
* ACP agent. `script` is the full multi-line snippet; the actual launch line
|
||||||
|
* is always the last line.
|
||||||
|
*/
|
||||||
|
export interface LaunchScript {
|
||||||
|
kind: DistributionKind;
|
||||||
|
/** Multi-line shell snippet: install steps (binary) + launch command. */
|
||||||
|
script: string;
|
||||||
|
/** Short source label, e.g. "npx" or "Binary (Linux x64)". */
|
||||||
|
sourceLabel: string;
|
||||||
|
/** Optional caveat, e.g. when no release exists for the detected platform. */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteShell(value: string): string {
|
||||||
|
return /^[a-zA-Z0-9_./~-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageScript(
|
||||||
|
kind: "npx" | "uvx",
|
||||||
|
dist: { package: string; args?: string[]; env?: Record<string, string> },
|
||||||
|
): LaunchScript {
|
||||||
|
const envPrefix = Object.entries(dist.env ?? {})
|
||||||
|
.map(([key, value]) => `${key}=${quoteShell(value)}`)
|
||||||
|
.join(" ");
|
||||||
|
const args = (dist.args ?? []).join(" ");
|
||||||
|
const script = `${envPrefix ? `${envPrefix} ` : ""}${kind} ${dist.package}${args ? ` ${args}` : ""}`;
|
||||||
|
return { kind, script, sourceLabel: kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveKind = "tar.gz" | "tar.bz2" | "zip" | "unknown";
|
||||||
|
|
||||||
|
function archiveKind(archive: string): ArchiveKind {
|
||||||
|
const url = archive.toLowerCase();
|
||||||
|
if (url.includes(".tar.gz") || url.includes(".tgz")) return "tar.gz";
|
||||||
|
if (url.includes(".tar.bz2") || url.includes(".tbz2") || url.includes(".tar.bz")) {
|
||||||
|
return "tar.bz2";
|
||||||
|
}
|
||||||
|
if (url.includes(".zip")) return "zip";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryScript(
|
||||||
|
agent: AcpAgent,
|
||||||
|
target: AcpBinaryTarget,
|
||||||
|
platformLabel: string,
|
||||||
|
isNative: boolean,
|
||||||
|
): LaunchScript {
|
||||||
|
const runArgs = (target.args ?? []).join(" ");
|
||||||
|
const runLine = `${target.cmd}${runArgs ? ` ${runArgs}` : ""}`;
|
||||||
|
|
||||||
|
// No release for the detected platform: still show the run command and
|
||||||
|
// point the user at the archive for manual installation.
|
||||||
|
if (!isNative) {
|
||||||
|
return {
|
||||||
|
kind: "binary",
|
||||||
|
script: runLine,
|
||||||
|
sourceLabel: `Binary (${platformLabel})`,
|
||||||
|
note: `No release for your platform — download the ${platformLabel} archive manually.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = `~/.acp-agents/${agent.id}`;
|
||||||
|
const kind = archiveKind(target.archive);
|
||||||
|
const archiveName =
|
||||||
|
kind === "unknown"
|
||||||
|
? "acp-agent-archive"
|
||||||
|
: `acp-agent-archive${kind === "zip" ? ".zip" : kind === "tar.bz2" ? ".tar.bz2" : ".tar.gz"}`;
|
||||||
|
const extract =
|
||||||
|
kind === "zip" ? `unzip -q ${archiveName} -d ${dir}` : `tar -xf ${archiveName} -C ${dir}`;
|
||||||
|
|
||||||
|
const script = [
|
||||||
|
`# Install ${agent.name} v${agent.version}`,
|
||||||
|
`curl -fsSL "${target.archive}" -o ${archiveName}`,
|
||||||
|
`mkdir -p ${dir}`,
|
||||||
|
extract,
|
||||||
|
`# Launch (ACP over stdio)`,
|
||||||
|
`${dir}/${runLine}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { kind: "binary", script, sourceLabel: `Binary (${platformLabel})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the launch snippet for an ACP agent on the given platform.
|
||||||
|
*
|
||||||
|
* Resolution order: npx → uvx → binary. Binary agents require a detected
|
||||||
|
* platform; when the platform is unknown or has no release, a run-only
|
||||||
|
* snippet with an explanatory note is returned instead of nothing.
|
||||||
|
*/
|
||||||
|
export function buildLaunchCommand(
|
||||||
|
agent: AcpAgent,
|
||||||
|
platform: PlatformInfo | null,
|
||||||
|
): LaunchScript | null {
|
||||||
|
const dist = agent.distribution;
|
||||||
|
if (!dist) return null;
|
||||||
|
|
||||||
|
if (dist.npx) return packageScript("npx", dist.npx);
|
||||||
|
if (dist.uvx) return packageScript("uvx", dist.uvx);
|
||||||
|
|
||||||
|
const binary = dist.binary;
|
||||||
|
if (binary) {
|
||||||
|
if (platform) {
|
||||||
|
const target = binary[platform.platform];
|
||||||
|
if (target) return binaryScript(agent, target, platform.label, true);
|
||||||
|
}
|
||||||
|
// No native release (or undetectable platform): fall back to the first
|
||||||
|
// available target so the run command is still visible.
|
||||||
|
const first = firstBinaryTarget(binary);
|
||||||
|
if (first) {
|
||||||
|
return binaryScript(agent, first.target, first.platform, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First available binary target, used as a fallback for display purposes. */
|
||||||
|
export function firstBinaryTarget(
|
||||||
|
binary: AcpDistribution["binary"],
|
||||||
|
): { platform: AcpPlatform; target: AcpBinaryTarget } | null {
|
||||||
|
if (!binary) return null;
|
||||||
|
const entry = Object.entries(binary).find(
|
||||||
|
(candidate): candidate is [AcpPlatform, AcpBinaryTarget] => Boolean(candidate[1]),
|
||||||
|
);
|
||||||
|
return entry ? { platform: entry[0], target: entry[1] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comma-separated list of platforms an agent publishes binary releases for. */
|
||||||
|
export function binaryPlatformsLabel(dist: AcpDistribution["binary"]): string {
|
||||||
|
if (!dist) return "";
|
||||||
|
return Object.keys(dist)
|
||||||
|
.filter((platform) => Boolean(dist[platform as AcpPlatform]))
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { AcpAgent, AcpRegistry } from "./types";
|
||||||
|
|
||||||
|
/** Official ACP registry, served from the Agent Client Protocol CDN. */
|
||||||
|
export const ACP_REGISTRY_URL =
|
||||||
|
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide cache so multiple pages (Agents, Agent Details, the backends
|
||||||
|
* catalog) share a single fetch per session.
|
||||||
|
*/
|
||||||
|
let cache: { registry: AcpRegistry; fetchedAt: number } | null = null;
|
||||||
|
|
||||||
|
export function fetchAcpRegistry(signal?: AbortSignal): Promise<AcpRegistry> {
|
||||||
|
return fetch(ACP_REGISTRY_URL, { signal, headers: { accept: "application/json" } }).then(
|
||||||
|
async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Registry request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
const registry = (await response.json()) as AcpRegistry;
|
||||||
|
if (!registry || !Array.isArray(registry.agents)) {
|
||||||
|
throw new Error("Registry payload is not a valid ACP registry");
|
||||||
|
}
|
||||||
|
cache = { registry, fetchedAt: Date.now() };
|
||||||
|
return registry;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpRegistryState {
|
||||||
|
agents: AcpAgent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the ACP registry once and share it across the app. `reload` re-fetches
|
||||||
|
* (clearing the cache) and is exposed for the error/retry states in the UI.
|
||||||
|
*/
|
||||||
|
export function useAcpRegistry(): AcpRegistryState {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
agents: AcpAgent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}>({ agents: [], isLoading: true, error: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cache) {
|
||||||
|
setState({ agents: cache.registry.agents, isLoading: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setState((current) => ({ ...current, isLoading: true, error: null }));
|
||||||
|
fetchAcpRegistry(controller.signal)
|
||||||
|
.then((registry) => {
|
||||||
|
setState({ agents: registry.agents, isLoading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to load the ACP registry";
|
||||||
|
setState({ agents: [], isLoading: false, error: message });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [attempt]);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
cache = null;
|
||||||
|
setAttempt((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Agent Client Protocol (ACP) registry types.
|
||||||
|
*
|
||||||
|
* Mirrors the JSON served by the ACP registry:
|
||||||
|
* https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json
|
||||||
|
*
|
||||||
|
* Each entry describes an ACP-compatible coding agent and how to launch it
|
||||||
|
* (npx package, uvx package, or per-platform binary release archives).
|
||||||
|
*/
|
||||||
|
export type AcpPlatform =
|
||||||
|
| "darwin-aarch64"
|
||||||
|
| "darwin-x86_64"
|
||||||
|
| "linux-aarch64"
|
||||||
|
| "linux-x86_64"
|
||||||
|
| "windows-x86_64";
|
||||||
|
|
||||||
|
export interface AcpPackageDistribution {
|
||||||
|
/** npm (npx) or PyPI (uvx) package spec, including version. e.g. "@agentclientprotocol/[email protected]" */
|
||||||
|
package: string;
|
||||||
|
/** Extra CLI args to pass when launching the agent. */
|
||||||
|
args?: string[];
|
||||||
|
/** Environment variables to set when launching the agent. */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpBinaryTarget {
|
||||||
|
/** Download URL of the release archive for this platform. */
|
||||||
|
archive: string;
|
||||||
|
/** Command to run after extraction, relative to the extracted directory. */
|
||||||
|
cmd: string;
|
||||||
|
/** Extra CLI args to pass when launching the agent. */
|
||||||
|
args?: string[];
|
||||||
|
/** SHA-256 checksum of the archive. */
|
||||||
|
sha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpDistribution {
|
||||||
|
/** Launch via npx. Preferred when present — works anywhere Node.js is installed. */
|
||||||
|
npx?: AcpPackageDistribution;
|
||||||
|
/** Launch via uvx (Python). */
|
||||||
|
uvx?: AcpPackageDistribution;
|
||||||
|
/** Per-platform binary release archives. */
|
||||||
|
binary?: Partial<Record<AcpPlatform, AcpBinaryTarget>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
repository?: string;
|
||||||
|
website?: string;
|
||||||
|
authors?: string[];
|
||||||
|
license?: string;
|
||||||
|
icon?: string;
|
||||||
|
distribution?: AcpDistribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpRegistry {
|
||||||
|
version: string;
|
||||||
|
agents: AcpAgent[];
|
||||||
|
extensions?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The execution backend configured for a Attendance agent — an ACP-compatible
|
||||||
|
* coding agent from the registry (Codex, Claude Agent, Gemini CLI, …) that
|
||||||
|
* acts as the harness. `agentName`/`version`/`icon` are captured from the
|
||||||
|
* registry when the backend is selected so the UI keeps working offline later.
|
||||||
|
*/
|
||||||
|
export interface AcpBackend {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
version: string;
|
||||||
|
/** Registry icon URL, shown next to the backend label. */
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-agent model override. When absent, the backend harness uses
|
||||||
|
* its own default model.
|
||||||
|
*/
|
||||||
|
export interface AgentModelOverride {
|
||||||
|
/** Stable configured-provider ID. */
|
||||||
|
providerId: string;
|
||||||
|
/** Provider name captured for offline display. */
|
||||||
|
providerName: string;
|
||||||
|
/** Stable model ID sent to the provider API. */
|
||||||
|
modelId: string;
|
||||||
|
/** Model name captured for offline display. */
|
||||||
|
modelName: string;
|
||||||
|
/** Environment values injected when this agent process launches. */
|
||||||
|
environment?: import("../../llm-providers/lib/data").AgentEnvironmentVariable[];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AgentDetailsPage } from "../containers/AgentDetailsPage";
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { ActivityTrendCard } from "../components/ActivityTrendCard";
|
||||||
|
import { OverviewKpis } from "../components/OverviewKpis";
|
||||||
|
import { TasksView } from "../../tasks/containers/TasksView";
|
||||||
|
import type { AgentDetailsContext } from "../containers/AgentDetailsPage";
|
||||||
|
|
||||||
|
/** Overview tab: KPIs, activity trend, and the agent's recent tasks. */
|
||||||
|
export function AgentOverviewSection() {
|
||||||
|
const { agent } = useOutletContext<AgentDetailsContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={6}>
|
||||||
|
<OverviewKpis agent={agent} />
|
||||||
|
<ActivityTrendCard agent={agent} />
|
||||||
|
<TasksView
|
||||||
|
heading="Recent tasks"
|
||||||
|
agentName={agent.name}
|
||||||
|
agentTaskTitles={agent.activeTaskTitles}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Permissions } from "../containers/Permissions";
|
||||||
|
|
||||||
|
/** Team access tab: member and role table for the agent. */
|
||||||
|
export function AgentPermissionsSection() {
|
||||||
|
return <Permissions />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { AgentSettings } from "../containers/AgentSettings";
|
||||||
|
import type { AgentDetailsContext } from "../containers/AgentDetailsPage";
|
||||||
|
|
||||||
|
/** Settings tab: agent configuration forms. */
|
||||||
|
export function AgentSettingsSection() {
|
||||||
|
const { agent } = useOutletContext<AgentDetailsContext>();
|
||||||
|
|
||||||
|
return <AgentSettings agent={agent} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { TasksView } from "../../tasks/containers/TasksView";
|
||||||
|
import type { AgentDetailsContext } from "../containers/AgentDetailsPage";
|
||||||
|
|
||||||
|
/** Tasks tab: the agent's full task list. */
|
||||||
|
export function AgentTasksSection() {
|
||||||
|
const { agent } = useOutletContext<AgentDetailsContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TasksView
|
||||||
|
heading={`${agent.name} tasks`}
|
||||||
|
agentName={agent.name}
|
||||||
|
agentTaskTitles={agent.activeTaskTitles}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AgentsPage } from "../containers/AgentsPage";
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Banner } from "@astryxdesign/core/Banner";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { CodeBlock } from "@astryxdesign/core/CodeBlock";
|
||||||
|
import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutFooter,
|
||||||
|
Section,
|
||||||
|
VStack,
|
||||||
|
} from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { IconCheck, IconCode } from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
CUSTOM_GIT_PROVIDER_ID,
|
||||||
|
GitProviderSelector,
|
||||||
|
} from "../../git-providers/components/GitProviderSelector";
|
||||||
|
import { GitRepositorySelector } from "../../git-providers/components/GitRepositorySelector";
|
||||||
|
import { previewManifest } from "../lib/agent-plugins-ui";
|
||||||
|
|
||||||
|
interface InstallPluginDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
gitProviderId: string;
|
||||||
|
onGitProviderChange: (providerId: string) => void;
|
||||||
|
repositoryUrl: string;
|
||||||
|
onRepositoryUrlChange: (value: string) => void;
|
||||||
|
version: string;
|
||||||
|
onVersionChange: (value: string) => void;
|
||||||
|
isValidated: boolean;
|
||||||
|
onValidate: () => void;
|
||||||
|
onInstall: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPluginDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
gitProviderId,
|
||||||
|
onGitProviderChange,
|
||||||
|
repositoryUrl,
|
||||||
|
onRepositoryUrlChange,
|
||||||
|
version,
|
||||||
|
onVersionChange,
|
||||||
|
isValidated,
|
||||||
|
onValidate,
|
||||||
|
onInstall,
|
||||||
|
}: InstallPluginDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} onOpenChange={onOpenChange} purpose="form" width={720}>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
title="Install agent extension"
|
||||||
|
subtitle="Choose a Git connection or enter a custom repository URL."
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<LayoutContent padding={4}>
|
||||||
|
<VStack gap={5}>
|
||||||
|
<GitProviderSelector
|
||||||
|
value={gitProviderId}
|
||||||
|
onChange={onGitProviderChange}
|
||||||
|
label="Repository provider"
|
||||||
|
description="Account used to access the plugin repository."
|
||||||
|
includeCustomOption
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
{gitProviderId === CUSTOM_GIT_PROVIDER_ID ? (
|
||||||
|
<TextInput
|
||||||
|
label="Custom repository URL"
|
||||||
|
description="Git repository containing plugin.json at its root."
|
||||||
|
placeholder="https://github.com/example/my-plugin.git"
|
||||||
|
value={repositoryUrl}
|
||||||
|
onChange={onRepositoryUrlChange}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GitRepositorySelector
|
||||||
|
providerId={gitProviderId}
|
||||||
|
value={repositoryUrl}
|
||||||
|
onChange={onRepositoryUrlChange}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Version or ref"
|
||||||
|
description="Branch, tag, or commit to install."
|
||||||
|
value={version}
|
||||||
|
onChange={onVersionChange}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isValidated ? (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Banner
|
||||||
|
status="success"
|
||||||
|
title="Valid Agent Plugin"
|
||||||
|
description="Manifest targets Agent Plugins 1.0.0. One skill and one stdio MCP server were discovered."
|
||||||
|
icon={<Icon icon={IconCheck} size="md" />}
|
||||||
|
/>
|
||||||
|
<CodeBlock
|
||||||
|
code={previewManifest}
|
||||||
|
language="json"
|
||||||
|
title="plugin.json"
|
||||||
|
hasCopyButton
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
<HStack gap={2} wrap="wrap">
|
||||||
|
<Badge variant="purple" label="1 Agent Skill" />
|
||||||
|
<Badge variant="blue" label="1 stdio MCP server" />
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Section variant="muted" padding={4}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Icon icon={IconCode} size="md" color="secondary" />
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Attendance verifies plugin.json, package boundaries, skills, and mcp.json
|
||||||
|
before installation.
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<LayoutFooter hasDivider>
|
||||||
|
<HStack gap={2} hAlign="end">
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={() => onOpenChange(false)} />
|
||||||
|
{isValidated ? (
|
||||||
|
<Button label="Install plugin" variant="primary" onClick={onInstall} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label="Validate plugin"
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={!gitProviderId || !repositoryUrl.trim() || !version.trim()}
|
||||||
|
onClick={onValidate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</LayoutFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, Section, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Table } from "@astryxdesign/core/Table";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconPlugConnected } from "@tabler/icons-react";
|
||||||
|
import { SectionHeader } from "../../../components/SectionHeader";
|
||||||
|
import { mcpTransportLabel, type AgentPlugin } from "../lib/agent-plugin-data";
|
||||||
|
import { pluginSkillColumns } from "../lib/agent-plugins-ui";
|
||||||
|
|
||||||
|
interface PluginComponentsSectionProps {
|
||||||
|
plugin: AgentPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginComponentsSection({ plugin }: PluginComponentsSectionProps) {
|
||||||
|
return (
|
||||||
|
<VStack gap={6}>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<SectionHeader
|
||||||
|
title="Skill overview"
|
||||||
|
description="Skills are discovered from immediate child directories under skills/."
|
||||||
|
/>
|
||||||
|
{plugin.skills.length > 0 ? (
|
||||||
|
<Card padding={0}>
|
||||||
|
<Table
|
||||||
|
data={plugin.skills}
|
||||||
|
columns={pluginSkillColumns}
|
||||||
|
idKey="name"
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
verticalAlign="middle"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Section variant="muted" padding={4}>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
This plugin does not declare Agent Skills.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack gap={3}>
|
||||||
|
<SectionHeader
|
||||||
|
title="MCP servers"
|
||||||
|
description="Portable server definitions loaded from the plugin's root mcp.json."
|
||||||
|
/>
|
||||||
|
{plugin.mcpServers.length > 0 ? (
|
||||||
|
<Grid columns={{ minWidth: 300, max: 2 }} gap={4}>
|
||||||
|
{plugin.mcpServers.map((server) => (
|
||||||
|
<Card key={server.name} padding={4}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Icon icon={IconPlugConnected} size="md" color="secondary" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{server.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{server.target}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Badge variant="blue" label={mcpTransportLabel[server.transport]} />
|
||||||
|
</HStack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Section variant="muted" padding={4}>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
This plugin does not declare MCP servers.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { CodeBlock } from "@astryxdesign/core/CodeBlock";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { SectionHeader } from "../../../components/SectionHeader";
|
||||||
|
import { pluginManifest, type AgentPlugin } from "../lib/agent-plugin-data";
|
||||||
|
|
||||||
|
interface PluginSettingsSectionProps {
|
||||||
|
plugin: AgentPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginSettingsSection({ plugin }: PluginSettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<VStack gap={8}>
|
||||||
|
<Grid columns={{ minWidth: 320, max: 2 }} gap={10} align="start">
|
||||||
|
<SectionHeader
|
||||||
|
title="Package configuration"
|
||||||
|
description="Package identity and Agent Plugins compatibility declared by this installation."
|
||||||
|
gap={1}
|
||||||
|
/>
|
||||||
|
<Grid columns={{ minWidth: 180, max: 2 }} gap={4}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Package
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.name}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Version
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.version}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Author
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.author}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Specification
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
Agent Plugins 1.0.0
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 320, max: 2 }} gap={10} align="start">
|
||||||
|
<SectionHeader
|
||||||
|
title="Installation source"
|
||||||
|
description="Source and installation details for this organization-managed package."
|
||||||
|
gap={1}
|
||||||
|
/>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Source
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.source}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Installed
|
||||||
|
</Text>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.installedAt}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 320, max: 2 }} gap={10} align="start">
|
||||||
|
<SectionHeader
|
||||||
|
title="Plugin manifest"
|
||||||
|
description="Read-only manifest for the installed package. Configuration is managed at its source."
|
||||||
|
gap={1}
|
||||||
|
/>
|
||||||
|
<CodeBlock
|
||||||
|
code={pluginManifest(plugin)}
|
||||||
|
language="json"
|
||||||
|
title="plugin.json"
|
||||||
|
hasCopyButton
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Outlet, useNavigate, useParams } from "react-router";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "@astryxdesign/core/Breadcrumbs";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { EmptyState } from "@astryxdesign/core/EmptyState";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Tab } from "@astryxdesign/core/TabList";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import type { TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconBox,
|
||||||
|
IconComponents,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconSettings,
|
||||||
|
IconUsers,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useRouteSection } from "../../../hooks/use-route-section";
|
||||||
|
import { BackButton } from "../../../components/BackButton";
|
||||||
|
import { DetailHero } from "../../../layouts/DetailHero";
|
||||||
|
import { HeroTabs } from "../../../layouts/HeroTabs";
|
||||||
|
import { getAgentPlugin, pluginStatusLabel, pluginStatusVariant } from "../lib/agent-plugin-data";
|
||||||
|
import type { AgentPlugin } from "../lib/agent-plugin-data";
|
||||||
|
import {
|
||||||
|
createAgentAccessColumns,
|
||||||
|
INITIAL_AGENT_ACCESS,
|
||||||
|
type AgentAccessRow,
|
||||||
|
type AgentPluginSection,
|
||||||
|
} from "../lib/agent-plugins-ui";
|
||||||
|
|
||||||
|
/** Shared state passed to the Agent Plugin detail section routes. */
|
||||||
|
export interface AgentPluginDetailsContext {
|
||||||
|
plugin: AgentPlugin;
|
||||||
|
agentAccess: AgentAccessRow[];
|
||||||
|
agentsWithAccess: number;
|
||||||
|
validSkills: number;
|
||||||
|
connectedMcp: number;
|
||||||
|
agentColumns: TableColumn<AgentAccessRow>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPluginDetailsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pluginId } = useParams();
|
||||||
|
const { activeSection, goToSection } = useRouteSection({
|
||||||
|
sections: ["components", "access", "settings"] as const,
|
||||||
|
fallback: "overview",
|
||||||
|
basePath: `/configure/agent-plugins/${pluginId}`,
|
||||||
|
});
|
||||||
|
const plugin = getAgentPlugin(pluginId);
|
||||||
|
const [agentAccess, setAgentAccess] = useState(INITIAL_AGENT_ACCESS);
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<VStack gap={5} width="100%">
|
||||||
|
<BackButton label="Back to Agent extensions" href="/configure/agent-plugins" />
|
||||||
|
<Card padding={8}>
|
||||||
|
<EmptyState
|
||||||
|
title="Plugin not found"
|
||||||
|
description="This Agent Plugin may have been removed or is unavailable to this organization."
|
||||||
|
icon={<Icon icon={IconBox} size="lg" color="secondary" />}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
label="View Agent extensions"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate("/configure/agent-plugins")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsWithAccess = agentAccess.filter((agent) => agent.hasAccess).length;
|
||||||
|
const validSkills = plugin.skills.filter((skill) => skill.status === "loaded").length;
|
||||||
|
const connectedMcp = plugin.mcpServers.filter((server) => server.status === "connected").length;
|
||||||
|
|
||||||
|
const handleAccessChange = (agentId: string, hasAccess: boolean) => {
|
||||||
|
setAgentAccess((current) =>
|
||||||
|
current.map((candidate) =>
|
||||||
|
candidate.id === agentId ? { ...candidate, hasAccess } : candidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentColumns = createAgentAccessColumns(handleAccessChange);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={6} width="100%">
|
||||||
|
<DetailHero
|
||||||
|
breadcrumbs={
|
||||||
|
<Breadcrumbs variant="supporting">
|
||||||
|
<BreadcrumbItem href="/configure/agent-plugins">Agent extensions</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem isCurrent>{plugin.name}</BreadcrumbItem>
|
||||||
|
</Breadcrumbs>
|
||||||
|
}
|
||||||
|
leading={<Icon icon={IconBox} size="lg" color="secondary" />}
|
||||||
|
status={
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot
|
||||||
|
variant={pluginStatusVariant[plugin.status]}
|
||||||
|
label={pluginStatusLabel[plugin.status]}
|
||||||
|
/>
|
||||||
|
<Text type="supporting" color="secondary" weight="semibold">
|
||||||
|
{pluginStatusLabel[plugin.status]}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
title={plugin.name}
|
||||||
|
metadata={
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Version {plugin.version} · {plugin.author}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
description={plugin.description}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconArrowLeft} size="sm" />}
|
||||||
|
onClick={() => navigate("/configure/agent-plugins")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeroTabs
|
||||||
|
label="Agent Plugin sections"
|
||||||
|
value={activeSection}
|
||||||
|
onChange={(value) => goToSection(value as AgentPluginSection)}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="overview"
|
||||||
|
label="Overview"
|
||||||
|
icon={<Icon icon={IconLayoutDashboard} size="sm" />}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="components"
|
||||||
|
label="Components"
|
||||||
|
icon={<Icon icon={IconComponents} size="sm" />}
|
||||||
|
/>
|
||||||
|
<Tab value="access" label="Agent access" icon={<Icon icon={IconUsers} size="sm" />} />
|
||||||
|
<Tab value="settings" label="Settings" icon={<Icon icon={IconSettings} size="sm" />} />
|
||||||
|
</HeroTabs>
|
||||||
|
|
||||||
|
<Outlet
|
||||||
|
context={{
|
||||||
|
plugin,
|
||||||
|
agentAccess,
|
||||||
|
agentsWithAccess,
|
||||||
|
validSkills,
|
||||||
|
connectedMcp,
|
||||||
|
agentColumns,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Banner } from "@astryxdesign/core/Banner";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { IconPlus, IconSearch, IconShieldCheck } from "@tabler/icons-react";
|
||||||
|
import { TableSection } from "../../../components/TableSection";
|
||||||
|
import { StatCardGrid } from "../../../components/StatCard";
|
||||||
|
import { useFilteredList } from "../../../hooks/use-filtered-list";
|
||||||
|
import { PageHero } from "../../../layouts/PageHero";
|
||||||
|
import { agentPlugins, type AgentPlugin } from "../lib/agent-plugin-data";
|
||||||
|
import { InstallPluginDialog } from "../components/InstallPluginDialog";
|
||||||
|
import { createPluginTableColumns, type PluginFilter } from "../lib/agent-plugins-ui";
|
||||||
|
|
||||||
|
export function AgentPluginsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [plugins, setPlugins] = useState<AgentPlugin[]>(agentPlugins);
|
||||||
|
const [filter, setFilter] = useState<PluginFilter>("all");
|
||||||
|
const [isInstallOpen, setIsInstallOpen] = useState(false);
|
||||||
|
const [gitProviderId, setGitProviderId] = useState("");
|
||||||
|
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||||
|
const [version, setVersion] = useState("main");
|
||||||
|
const [isValidated, setIsValidated] = useState(false);
|
||||||
|
|
||||||
|
const enabledCount = plugins.filter((plugin) => plugin.status === "enabled").length;
|
||||||
|
const skillCount = plugins.reduce((count, plugin) => count + plugin.skills.length, 0);
|
||||||
|
const mcpCount = plugins.reduce((count, plugin) => count + plugin.mcpServers.length, 0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
query: search,
|
||||||
|
setQuery: setSearch,
|
||||||
|
filtered: filteredPlugins,
|
||||||
|
} = useFilteredList(plugins, {
|
||||||
|
searchFields: (plugin) => [plugin.name, plugin.description, plugin.author],
|
||||||
|
predicate: (plugin) =>
|
||||||
|
filter === "all" ||
|
||||||
|
(filter === "enabled" && plugin.status === "enabled") ||
|
||||||
|
(filter === "issues" && plugin.status === "partial"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = createPluginTableColumns(navigate);
|
||||||
|
|
||||||
|
const resetInstall = () => {
|
||||||
|
setGitProviderId("");
|
||||||
|
setRepositoryUrl("");
|
||||||
|
setVersion("main");
|
||||||
|
setIsValidated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const installPlugin = () => {
|
||||||
|
const plugin: AgentPlugin = {
|
||||||
|
id: `plugin-${crypto.randomUUID()}`,
|
||||||
|
name: "example.portable-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "A portable Agent Plugin loaded from the selected repository.",
|
||||||
|
author: "Plugin author",
|
||||||
|
source: repositoryUrl.trim(),
|
||||||
|
status: "enabled",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "example-skill",
|
||||||
|
description: "Reusable instructions packaged with this plugin.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
name: "example-tools",
|
||||||
|
transport: "stdio",
|
||||||
|
target: "./bin/example-tools",
|
||||||
|
status: "connected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: 0,
|
||||||
|
installedAt: "Just now",
|
||||||
|
};
|
||||||
|
setPlugins((current) => [...current, plugin]);
|
||||||
|
setIsInstallOpen(false);
|
||||||
|
resetInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitProviderChange = (providerId: string) => {
|
||||||
|
setGitProviderId(providerId);
|
||||||
|
setRepositoryUrl("");
|
||||||
|
setIsValidated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRepositoryUrlChange = (value: string) => {
|
||||||
|
setRepositoryUrl(value);
|
||||||
|
setIsValidated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVersionChange = (value: string) => {
|
||||||
|
setVersion(value);
|
||||||
|
setIsValidated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallOpenChange = (isOpen: boolean) => {
|
||||||
|
setIsInstallOpen(isOpen);
|
||||||
|
if (!isOpen) resetInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VStack gap={6} width="100%">
|
||||||
|
<PageHero
|
||||||
|
eyebrow="Integrations"
|
||||||
|
title="Agent extensions"
|
||||||
|
description="Install portable packages containing Agent Skills and MCP servers."
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
label="Install extension"
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon icon={IconPlus} size="sm" />}
|
||||||
|
onClick={() => setIsInstallOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
status="info"
|
||||||
|
title="Agent Plugins 1.0.0"
|
||||||
|
description="Attendance discovers plugin.json, loads Agent Skills from skills/, and maps MCP servers from mcp.json. Invalid components are isolated so the rest of a plugin can continue loading."
|
||||||
|
icon={<Icon icon={IconShieldCheck} size="md" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCardGrid
|
||||||
|
minWidth={200}
|
||||||
|
max={3}
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
label: "Enabled extensions",
|
||||||
|
value: enabledCount,
|
||||||
|
hint: `of ${plugins.length} installed`,
|
||||||
|
},
|
||||||
|
{ label: "Agent Skills", value: skillCount, hint: "discovered from skills/" },
|
||||||
|
{ label: "MCP servers", value: mcpCount, hint: "declared in mcp.json" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VStack gap={3}>
|
||||||
|
<TextInput
|
||||||
|
label="Search plugins"
|
||||||
|
isLabelHidden
|
||||||
|
placeholder="Search by name, description, or author"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
startIcon={IconSearch}
|
||||||
|
hasClear
|
||||||
|
/>
|
||||||
|
<TableSection
|
||||||
|
title="Installed plugins"
|
||||||
|
description="Open a plugin to inspect components, health, and agent access."
|
||||||
|
toolbar={
|
||||||
|
<SegmentedControl
|
||||||
|
label="Plugin filter"
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as PluginFilter)}
|
||||||
|
>
|
||||||
|
<SegmentedControlItem value="all" label="All" />
|
||||||
|
<SegmentedControlItem value="enabled" label="Enabled" />
|
||||||
|
<SegmentedControlItem value="issues" label="Issues" />
|
||||||
|
</SegmentedControl>
|
||||||
|
}
|
||||||
|
columns={columns}
|
||||||
|
data={filteredPlugins}
|
||||||
|
idKey="id"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<InstallPluginDialog
|
||||||
|
isOpen={isInstallOpen}
|
||||||
|
onOpenChange={handleInstallOpenChange}
|
||||||
|
gitProviderId={gitProviderId}
|
||||||
|
onGitProviderChange={handleGitProviderChange}
|
||||||
|
repositoryUrl={repositoryUrl}
|
||||||
|
onRepositoryUrlChange={handleRepositoryUrlChange}
|
||||||
|
version={version}
|
||||||
|
onVersionChange={handleVersionChange}
|
||||||
|
isValidated={isValidated}
|
||||||
|
onValidate={() => setIsValidated(true)}
|
||||||
|
onInstall={installPlugin}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { createSeedStore, createStatusMaps } from "../../../lib/data-factories";
|
||||||
|
|
||||||
|
export type PluginStatus = "enabled" | "partial" | "disabled";
|
||||||
|
export type McpTransport = "stdio" | "streamable-http" | "sse";
|
||||||
|
|
||||||
|
export interface PluginSkill extends Record<string, unknown> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "loaded" | "invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginMcpServer {
|
||||||
|
name: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
target: string;
|
||||||
|
status: "connected" | "stopped" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPlugin extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
source: string;
|
||||||
|
status: PluginStatus;
|
||||||
|
skills: PluginSkill[];
|
||||||
|
mcpServers: PluginMcpServer[];
|
||||||
|
extensions: number;
|
||||||
|
installedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentPluginStore = createSeedStore<AgentPlugin>([
|
||||||
|
{
|
||||||
|
id: "deployment-tools",
|
||||||
|
name: "deployment.tools",
|
||||||
|
version: "1.4.0",
|
||||||
|
description: "Deployment runbooks and infrastructure validation tools.",
|
||||||
|
author: "Acme Platform",
|
||||||
|
source: "github.com/acme/deployment-tools",
|
||||||
|
status: "enabled",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "deploy",
|
||||||
|
description: "Deploy services using approved environment workflows.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rollback",
|
||||||
|
description: "Diagnose and roll back failed deployments.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
name: "deployment-api",
|
||||||
|
transport: "streamable-http",
|
||||||
|
target: "https://deploy.acme.dev/mcp",
|
||||||
|
status: "connected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: 1,
|
||||||
|
installedAt: "2 days ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github-workflow",
|
||||||
|
name: "github.workflow",
|
||||||
|
version: "2.1.0",
|
||||||
|
description: "Pull-request review skills and GitHub repository tools.",
|
||||||
|
author: "Attendance",
|
||||||
|
source: "github.com/attendance/plugins/github-workflow",
|
||||||
|
status: "enabled",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "review-pull-request",
|
||||||
|
description: "Review pull requests and summarize actionable findings.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
name: "github-tools",
|
||||||
|
transport: "stdio",
|
||||||
|
target: "./bin/github-tools",
|
||||||
|
status: "connected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: 0,
|
||||||
|
installedAt: "1 week ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs-research",
|
||||||
|
name: "docs.research",
|
||||||
|
version: "1.0.3",
|
||||||
|
description: "Documentation research, summarization, and citation skills.",
|
||||||
|
author: "Open Research",
|
||||||
|
source: "gitlab.com/open-research/docs-plugin",
|
||||||
|
status: "enabled",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "research",
|
||||||
|
description: "Research technical questions using trusted sources.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "summarize",
|
||||||
|
description: "Create concise summaries from long documents.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
{ name: "cite", description: "Format and verify citations.", status: "loaded" },
|
||||||
|
],
|
||||||
|
mcpServers: [],
|
||||||
|
extensions: 0,
|
||||||
|
installedAt: "3 weeks ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "incident-response",
|
||||||
|
name: "incident.response",
|
||||||
|
version: "0.9.0",
|
||||||
|
description: "Incident triage skills with an observability MCP server.",
|
||||||
|
author: "Acme Reliability",
|
||||||
|
source: "github.com/acme/incident-response-plugin",
|
||||||
|
status: "partial",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
name: "triage",
|
||||||
|
description: "Classify incidents and suggest initial responders.",
|
||||||
|
status: "loaded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postmortem",
|
||||||
|
description: "Generate an incident postmortem from the event timeline.",
|
||||||
|
status: "invalid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
name: "observability",
|
||||||
|
transport: "stdio",
|
||||||
|
target: "./bin/observability",
|
||||||
|
status: "connected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: 0,
|
||||||
|
installedAt: "1 month ago",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const agentPlugins = agentPluginStore.list;
|
||||||
|
|
||||||
|
const pluginStatusMaps = createStatusMaps({
|
||||||
|
enabled: { label: "Enabled", variant: "success" },
|
||||||
|
partial: { label: "Partially loaded", variant: "warning" },
|
||||||
|
disabled: { label: "Disabled", variant: "neutral" },
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export const pluginStatusLabel = pluginStatusMaps.label;
|
||||||
|
export const pluginStatusVariant = pluginStatusMaps.variant;
|
||||||
|
|
||||||
|
export const mcpTransportLabel: Record<McpTransport, string> = {
|
||||||
|
stdio: "stdio",
|
||||||
|
"streamable-http": "Streamable HTTP",
|
||||||
|
sse: "SSE",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAgentPlugin = agentPluginStore.getById;
|
||||||
|
|
||||||
|
export function pluginManifest(plugin: AgentPlugin) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
description: plugin.description,
|
||||||
|
author: { name: plugin.author },
|
||||||
|
repository: plugin.source,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Switch } from "@astryxdesign/core/Switch";
|
||||||
|
import { proportional, pixel, type TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconBox, IconBrain, IconRobot, IconSettings } from "@tabler/icons-react";
|
||||||
|
import type { NavigateFunction } from "react-router";
|
||||||
|
import { createStatusMaps } from "../../../lib/data-factories";
|
||||||
|
import {
|
||||||
|
pluginStatusLabel,
|
||||||
|
pluginStatusVariant,
|
||||||
|
type AgentPlugin,
|
||||||
|
type PluginSkill,
|
||||||
|
} from "../lib/agent-plugin-data";
|
||||||
|
|
||||||
|
export type PluginFilter = "all" | "enabled" | "issues";
|
||||||
|
|
||||||
|
export type AgentPluginSection = "overview" | "components" | "access" | "settings";
|
||||||
|
|
||||||
|
export interface AgentAccessRow extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
model: string;
|
||||||
|
hasAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_AGENT_ACCESS: AgentAccessRow[] = [
|
||||||
|
{
|
||||||
|
id: "code-reviewer",
|
||||||
|
name: "Code Reviewer",
|
||||||
|
description: "Reviews pull requests and recommends focused changes.",
|
||||||
|
model: "Claude Sonnet 4",
|
||||||
|
hasAccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "research-scout",
|
||||||
|
name: "Research Scout",
|
||||||
|
description: "Researches technical questions and returns sourced findings.",
|
||||||
|
model: "DeepSeek V4",
|
||||||
|
hasAccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-triage",
|
||||||
|
name: "Issue Triage",
|
||||||
|
description: "Classifies issues and recommends ownership and priority.",
|
||||||
|
model: "GPT-5.2",
|
||||||
|
hasAccess: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "release-manager",
|
||||||
|
name: "Release Manager",
|
||||||
|
description: "Coordinates release readiness and deployment checks.",
|
||||||
|
model: "Claude Sonnet 4",
|
||||||
|
hasAccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-auditor",
|
||||||
|
name: "Security Auditor",
|
||||||
|
description: "Reviews sensitive changes and vulnerable dependencies.",
|
||||||
|
model: "Not configured",
|
||||||
|
hasAccess: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pluginSkillStatusMaps = createStatusMaps({
|
||||||
|
loaded: { label: "Loaded", variant: "success" },
|
||||||
|
invalid: { label: "Invalid", variant: "warning" },
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export const pluginSkillStatusLabel = pluginSkillStatusMaps.label;
|
||||||
|
export const pluginSkillStatusVariant = pluginSkillStatusMaps.variant;
|
||||||
|
|
||||||
|
export const previewManifest = JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||||
|
name: "example.portable-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "A portable Agent Plugin loaded from the selected repository.",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function createPluginTableColumns(navigate: NavigateFunction): TableColumn<AgentPlugin>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Plugin",
|
||||||
|
width: proportional(1.4),
|
||||||
|
renderCell: (plugin) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Icon icon={IconBox} size="md" color="secondary" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{plugin.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
v{plugin.version}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{plugin.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skills",
|
||||||
|
header: "Components",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (plugin) => (
|
||||||
|
<HStack gap={1} wrap="wrap">
|
||||||
|
{plugin.skills.length > 0 ? (
|
||||||
|
<Badge variant="purple" label={`${plugin.skills.length} skills`} />
|
||||||
|
) : null}
|
||||||
|
{plugin.mcpServers.length > 0 ? (
|
||||||
|
<Badge variant="blue" label={`${plugin.mcpServers.length} MCP`} />
|
||||||
|
) : null}
|
||||||
|
{plugin.extensions > 0 ? (
|
||||||
|
<Badge variant="teal" label={`${plugin.extensions} extension`} />
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: proportional(0.8),
|
||||||
|
renderCell: (plugin) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot
|
||||||
|
variant={pluginStatusVariant[plugin.status]}
|
||||||
|
label={pluginStatusLabel[plugin.status]}
|
||||||
|
/>
|
||||||
|
<Text type="supporting">{pluginStatusLabel[plugin.status]}</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Source",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (plugin) => (
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="supporting">{plugin.author}</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Installed {plugin.installedAt}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "id",
|
||||||
|
header: "",
|
||||||
|
width: pixel(112),
|
||||||
|
align: "end",
|
||||||
|
renderCell: (plugin) => (
|
||||||
|
<Button
|
||||||
|
label="Manage"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconSettings} size="sm" />}
|
||||||
|
onClick={() => navigate(`/configure/agent-plugins/${plugin.id}`)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginSkillColumns: TableColumn<PluginSkill>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Skill",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (skill) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Icon icon={IconBrain} size="md" color="secondary" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{skill.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{skill.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Load status",
|
||||||
|
width: pixel(132),
|
||||||
|
renderCell: (skill) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot
|
||||||
|
variant={pluginSkillStatusVariant[skill.status]}
|
||||||
|
label={pluginSkillStatusLabel[skill.status]}
|
||||||
|
/>
|
||||||
|
<Text type="supporting">{pluginSkillStatusLabel[skill.status]}</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAgentAccessColumns(
|
||||||
|
onAccessChange: (agentId: string, hasAccess: boolean) => void,
|
||||||
|
): TableColumn<AgentAccessRow>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Agent",
|
||||||
|
width: proportional(1.2),
|
||||||
|
renderCell: (agent) => (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<Icon icon={IconRobot} size="md" color="secondary" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{agent.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{agent.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
header: "Model",
|
||||||
|
width: proportional(0.7),
|
||||||
|
renderCell: (agent) => <Text type="supporting">{agent.model}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hasAccess",
|
||||||
|
header: "Plugin access",
|
||||||
|
width: pixel(150),
|
||||||
|
align: "end",
|
||||||
|
renderCell: (agent) => (
|
||||||
|
<Switch
|
||||||
|
label={`${agent.name} plugin access`}
|
||||||
|
isLabelHidden
|
||||||
|
value={agent.hasAccess}
|
||||||
|
onChange={(hasAccess) => onAccessChange(agent.id, hasAccess)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Card } from "@astryxdesign/core/Card";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Table } from "@astryxdesign/core/Table";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import type { AgentPluginDetailsContext } from "../containers/AgentPluginDetailsPage";
|
||||||
|
|
||||||
|
/** Agent access tab: which agents may load this plugin's components. */
|
||||||
|
export function AgentPluginAccessSection() {
|
||||||
|
const { agentAccess, agentsWithAccess, agentColumns } =
|
||||||
|
useOutletContext<AgentPluginDetailsContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={3}>
|
||||||
|
<HStack gap={3} hAlign="between" vAlign="end" wrap="wrap">
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={2}>Agent access</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Choose which agents may load this plugin's valid skills and MCP tools.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Badge variant="purple" label={`${agentsWithAccess} agents enabled`} />
|
||||||
|
</HStack>
|
||||||
|
<Card padding={0}>
|
||||||
|
<Table
|
||||||
|
data={agentAccess}
|
||||||
|
columns={agentColumns}
|
||||||
|
idKey="id"
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
verticalAlign="middle"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { PluginComponentsSection } from "../components/PluginComponentsSection";
|
||||||
|
import type { AgentPluginDetailsContext } from "../containers/AgentPluginDetailsPage";
|
||||||
|
|
||||||
|
/** Components tab: discovered skills and MCP servers. */
|
||||||
|
export function AgentPluginComponentsSection() {
|
||||||
|
const { plugin } = useOutletContext<AgentPluginDetailsContext>();
|
||||||
|
|
||||||
|
return <PluginComponentsSection plugin={plugin} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AgentPluginDetailsPage } from "../containers/AgentPluginDetailsPage";
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { Banner } from "@astryxdesign/core/Banner";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { IconShieldCheck } from "@tabler/icons-react";
|
||||||
|
import { StatCardGrid } from "../../../components/StatCard";
|
||||||
|
import type { AgentPluginDetailsContext } from "../containers/AgentPluginDetailsPage";
|
||||||
|
|
||||||
|
/** Overview tab: component KPIs and load-status banner. */
|
||||||
|
export function AgentPluginOverviewSection() {
|
||||||
|
const { plugin, agentAccess, agentsWithAccess, validSkills, connectedMcp } =
|
||||||
|
useOutletContext<AgentPluginDetailsContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<StatCardGrid
|
||||||
|
cards={[
|
||||||
|
{ label: "Version", value: plugin.version, hint: "Agent Plugins 1.0.0" },
|
||||||
|
{
|
||||||
|
label: "Loaded skills",
|
||||||
|
value: validSkills,
|
||||||
|
hint: `of ${plugin.skills.length} discovered`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "MCP servers",
|
||||||
|
value: connectedMcp,
|
||||||
|
hint: `of ${plugin.mcpServers.length} connected`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Agent access",
|
||||||
|
value: agentsWithAccess,
|
||||||
|
hint: `of ${agentAccess.length} agents`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{plugin.status === "partial" ? (
|
||||||
|
<Banner
|
||||||
|
status="warning"
|
||||||
|
title="Some components were skipped"
|
||||||
|
description="Invalid components are isolated. Valid skills and MCP servers remain available to permitted agents."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Banner
|
||||||
|
status="success"
|
||||||
|
title="Plugin loaded"
|
||||||
|
description="The manifest and all discovered portable components passed validation."
|
||||||
|
icon={<Icon icon={IconShieldCheck} size="md" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { PluginSettingsSection } from "../components/PluginSettingsSection";
|
||||||
|
import type { AgentPluginDetailsContext } from "../containers/AgentPluginDetailsPage";
|
||||||
|
|
||||||
|
/** Settings tab: package configuration details. */
|
||||||
|
export function AgentPluginSettingsSection() {
|
||||||
|
const { plugin } = useOutletContext<AgentPluginDetailsContext>();
|
||||||
|
|
||||||
|
return <PluginSettingsSection plugin={plugin} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AgentPluginsPage } from "../containers/AgentPluginsPage";
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Center } from "@astryxdesign/core/Center";
|
||||||
|
import { EmptyState } from "@astryxdesign/core/EmptyState";
|
||||||
|
import { useMediaQuery } from "@astryxdesign/core/hooks";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutHeader,
|
||||||
|
LayoutPanel,
|
||||||
|
Section,
|
||||||
|
StackItem,
|
||||||
|
VStack,
|
||||||
|
} from "@astryxdesign/core/Layout";
|
||||||
|
import { Markdown } from "@astryxdesign/core/Markdown";
|
||||||
|
import { MetadataList, MetadataListItem } from "@astryxdesign/core/MetadataList";
|
||||||
|
import { Selector } from "@astryxdesign/core/Selector";
|
||||||
|
import { Tab, TabList } from "@astryxdesign/core/TabList";
|
||||||
|
import { Text, Heading } from "@astryxdesign/core/Text";
|
||||||
|
import { TextArea } from "@astryxdesign/core/TextArea";
|
||||||
|
import { Toolbar } from "@astryxdesign/core/Toolbar";
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconCode,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
IconDownload,
|
||||||
|
IconFile,
|
||||||
|
IconFileTypePdf,
|
||||||
|
IconMarkdown,
|
||||||
|
IconMessage,
|
||||||
|
IconPhoto,
|
||||||
|
IconVideo,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useThemeMode } from "../../../lib/theme-mode";
|
||||||
|
import type {
|
||||||
|
Artifact,
|
||||||
|
ArtifactKind,
|
||||||
|
ArtifactReviewFeedback,
|
||||||
|
ArtifactViewerCallbacks,
|
||||||
|
} from "../lib/types";
|
||||||
|
import { isEditableArtifact } from "../lib/types";
|
||||||
|
|
||||||
|
export interface ArtifactViewerProps extends ArtifactViewerCallbacks {
|
||||||
|
artifact: Artifact;
|
||||||
|
artifacts?: Artifact[];
|
||||||
|
onBack?: () => void;
|
||||||
|
onSelectArtifact?: (artifactId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICON: Record<ArtifactKind, typeof IconFile> = {
|
||||||
|
pdf: IconFileTypePdf,
|
||||||
|
image: IconPhoto,
|
||||||
|
video: IconVideo,
|
||||||
|
markdown: IconMarkdown,
|
||||||
|
code: IconCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPROVAL_BADGE = {
|
||||||
|
not_required: { label: "No approval", variant: "neutral" as const },
|
||||||
|
pending: { label: "Approval required", variant: "warning" as const },
|
||||||
|
approved: { label: "Approved", variant: "success" as const },
|
||||||
|
changes_requested: { label: "Changes requested", variant: "error" as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDITOR_OPTIONS = {
|
||||||
|
automaticLayout: true,
|
||||||
|
fontSize: 14,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
padding: { top: 16 },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: "on" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ArtifactPreview({ artifact, content }: { artifact: Artifact; content: string }) {
|
||||||
|
if (artifact.kind === "markdown") {
|
||||||
|
return (
|
||||||
|
<LayoutContent
|
||||||
|
role="region"
|
||||||
|
aria-label="Markdown preview"
|
||||||
|
className="artifact-markdown-preview"
|
||||||
|
>
|
||||||
|
<Markdown headingLevelStart={1} contentWidth={760} contentAlign="center">
|
||||||
|
{content}
|
||||||
|
</Markdown>
|
||||||
|
</LayoutContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact.kind === "pdf" && artifact.src) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className="artifact-media-frame"
|
||||||
|
src={artifact.src}
|
||||||
|
title={`${artifact.title} PDF preview`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact.kind === "image" && artifact.src) {
|
||||||
|
return (
|
||||||
|
<Center axis="both" width="100%" height="100%" className="artifact-media-surface">
|
||||||
|
<img className="artifact-image" src={artifact.src} alt={artifact.description} />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact.kind === "video" && artifact.src) {
|
||||||
|
return (
|
||||||
|
<Center axis="both" width="100%" height="100%" className="artifact-media-surface">
|
||||||
|
<video className="artifact-video" src={artifact.src} controls aria-label={artifact.title} />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center axis="both" width="100%" height="100%">
|
||||||
|
<EmptyState
|
||||||
|
title="Preview unavailable"
|
||||||
|
description="This artifact does not include previewable content."
|
||||||
|
icon={<Icon icon={IconFile} size="lg" color="secondary" />}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactReviewPanel({
|
||||||
|
artifact,
|
||||||
|
isSubmitting,
|
||||||
|
onApprove,
|
||||||
|
onRequestChanges,
|
||||||
|
}: {
|
||||||
|
artifact: Artifact;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onApprove: (feedback: ArtifactReviewFeedback) => void;
|
||||||
|
onRequestChanges: (feedback: ArtifactReviewFeedback) => void;
|
||||||
|
}) {
|
||||||
|
const [decision, setDecision] = useState<"approve" | "reject" | null>(null);
|
||||||
|
const [comment, setComment] = useState(artifact.reviewFeedback?.comment ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDecision(null);
|
||||||
|
setComment(artifact.reviewFeedback?.comment ?? "");
|
||||||
|
}, [artifact.id, artifact.reviewFeedback]);
|
||||||
|
|
||||||
|
const requiresDecision = artifact.approvalStatus === "pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutPanel width={340} hasDivider padding={4} label="Artifact details">
|
||||||
|
<VStack gap={5}>
|
||||||
|
<Heading level={3}>Details</Heading>
|
||||||
|
|
||||||
|
<MetadataList label={{ position: "top" }}>
|
||||||
|
<MetadataListItem label="Version">v{artifact.version}</MetadataListItem>
|
||||||
|
<MetadataListItem label="Updated">{artifact.updatedAt}</MetadataListItem>
|
||||||
|
<MetadataListItem label="Type">{artifact.mimeType}</MetadataListItem>
|
||||||
|
</MetadataList>
|
||||||
|
|
||||||
|
{artifact.author ? (
|
||||||
|
<Section variant="muted" padding={3}>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<Avatar
|
||||||
|
src={artifact.author.imageUrl}
|
||||||
|
name={artifact.author.name}
|
||||||
|
alt={artifact.author.name}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<StackItem size="fill">
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="label" weight="semibold">
|
||||||
|
{artifact.author.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{artifact.agentState === "waiting_for_approval"
|
||||||
|
? "Waiting for your review"
|
||||||
|
: artifact.agentState === "running"
|
||||||
|
? "Working on this artifact"
|
||||||
|
: "Finished this artifact"}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
</HStack>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{requiresDecision ? (
|
||||||
|
<Section variant="muted" padding={3}>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label" weight="semibold">
|
||||||
|
Review artifact
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Approve to continue, or explain what should change.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
{decision ? (
|
||||||
|
<VStack gap={3}>
|
||||||
|
<TextArea
|
||||||
|
label={decision === "approve" ? "Approval comment" : "Changes requested"}
|
||||||
|
isOptional={decision === "approve"}
|
||||||
|
value={comment}
|
||||||
|
rows={4}
|
||||||
|
placeholder={
|
||||||
|
decision === "approve"
|
||||||
|
? "Add an optional note for the agent…"
|
||||||
|
: "Explain what the agent should change…"
|
||||||
|
}
|
||||||
|
onChange={setComment}
|
||||||
|
/>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDecision(null);
|
||||||
|
setComment("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={decision === "approve" ? "Approve and continue" : "Send changes"}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={(decision === "reject" && !comment.trim()) || isSubmitting}
|
||||||
|
onClick={() => {
|
||||||
|
const feedback = { comment: comment.trim() || undefined };
|
||||||
|
if (decision === "approve") onApprove(feedback);
|
||||||
|
else onRequestChanges(feedback);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Button
|
||||||
|
label="Approve and continue"
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon icon={IconCheck} size="sm" />}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
onClick={() => setDecision("approve")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Request changes"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconMessage} size="sm" />}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
onClick={() => setDecision("reject")}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!requiresDecision && artifact.reviewFeedback?.comment ? (
|
||||||
|
<Section variant="muted" padding={3}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text type="label" weight="semibold">
|
||||||
|
{artifact.approvalStatus === "approved" ? "Approval comment" : "Change request"}
|
||||||
|
</Text>
|
||||||
|
<Text type="body">{artifact.reviewFeedback.comment}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</VStack>
|
||||||
|
</LayoutPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactViewer({
|
||||||
|
artifact,
|
||||||
|
artifacts = [],
|
||||||
|
onBack,
|
||||||
|
onSelectArtifact,
|
||||||
|
onContentChange,
|
||||||
|
onSave,
|
||||||
|
onApprove,
|
||||||
|
onRequestChanges,
|
||||||
|
}: ArtifactViewerProps) {
|
||||||
|
const [draft, setDraft] = useState(artifact.content ?? "");
|
||||||
|
const [view, setView] = useState<"edit" | "preview">(
|
||||||
|
artifact.kind === "markdown" && artifact.approvalStatus !== "pending" ? "preview" : "edit",
|
||||||
|
);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const activeArtifactIdRef = useRef(artifact.id);
|
||||||
|
const { mode } = useThemeMode();
|
||||||
|
const systemIsDark = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
|
const monacoTheme = mode === "dark" || (mode === "system" && systemIsDark) ? "vs-dark" : "light";
|
||||||
|
const editable = isEditableArtifact(artifact);
|
||||||
|
const isDirty = editable && draft !== (artifact.content ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeArtifactIdRef.current === artifact.id) return;
|
||||||
|
activeArtifactIdRef.current = artifact.id;
|
||||||
|
setDraft(artifact.content ?? "");
|
||||||
|
setView(
|
||||||
|
artifact.kind === "markdown" && artifact.approvalStatus !== "pending" ? "preview" : "edit",
|
||||||
|
);
|
||||||
|
}, [artifact]);
|
||||||
|
|
||||||
|
const updateDraft = (value: string | undefined) => {
|
||||||
|
const nextValue = value ?? "";
|
||||||
|
setDraft(nextValue);
|
||||||
|
onContentChange?.(artifact.id, nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!onSave || !isDirty) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSave({ ...artifact, content: draft });
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approve = async (feedback: ArtifactReviewFeedback) => {
|
||||||
|
if (!onApprove) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onApprove({ ...artifact, content: draft }, feedback);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestChanges = async (feedback: ArtifactReviewFeedback) => {
|
||||||
|
if (!onRequestChanges) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onRequestChanges({ ...artifact, content: draft }, feedback);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<Editor
|
||||||
|
key={artifact.id}
|
||||||
|
height="100%"
|
||||||
|
language={artifact.language ?? "plaintext"}
|
||||||
|
value={draft}
|
||||||
|
theme={monacoTheme}
|
||||||
|
options={{ ...EDITOR_OPTIONS, readOnly: !onContentChange }}
|
||||||
|
loading={
|
||||||
|
<Center axis="both" width="100%" height="100%">
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
Loading editor…
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
onChange={updateDraft}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content =
|
||||||
|
editable && view === "edit" ? editor : <ArtifactPreview artifact={artifact} content={draft} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
header={
|
||||||
|
<LayoutHeader hasDivider padding={3}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
{onBack ? (
|
||||||
|
<Button
|
||||||
|
label="Back to task"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon icon={IconArrowLeft} size="sm" />}
|
||||||
|
isIconOnly
|
||||||
|
onClick={onBack}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Icon icon={KIND_ICON[artifact.kind]} size="md" color="secondary" />
|
||||||
|
<StackItem size="fill">
|
||||||
|
<VStack gap={0}>
|
||||||
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
|
<Heading level={1}>{artifact.title}</Heading>
|
||||||
|
{artifact.approvalStatus !== "not_required" ? (
|
||||||
|
<Badge
|
||||||
|
label={APPROVAL_BADGE[artifact.approvalStatus].label}
|
||||||
|
variant={APPROVAL_BADGE[artifact.approvalStatus].variant}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{artifact.name} · v{artifact.version} · {artifact.updatedAt}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
{artifact.src ? (
|
||||||
|
<Button
|
||||||
|
label="Open original"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon icon={IconDownload} size="sm" />}
|
||||||
|
onClick={() => window.open(artifact.src, "_blank", "noopener,noreferrer")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
</LayoutHeader>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<LayoutContent role="main" padding={0} className="artifact-viewer-content">
|
||||||
|
<VStack gap={0} height="100%">
|
||||||
|
<Toolbar
|
||||||
|
label="Artifact view controls"
|
||||||
|
size="sm"
|
||||||
|
dividers={["bottom"]}
|
||||||
|
startContent={
|
||||||
|
artifacts.length > 1 ? (
|
||||||
|
<Selector
|
||||||
|
label="Artifact"
|
||||||
|
isLabelHidden
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
width={260}
|
||||||
|
value={artifact.id}
|
||||||
|
startIcon={<Icon icon={KIND_ICON[artifact.kind]} size="sm" color="secondary" />}
|
||||||
|
options={artifacts.map((candidate) => ({
|
||||||
|
value: candidate.id,
|
||||||
|
label: candidate.name,
|
||||||
|
icon: <Icon icon={KIND_ICON[candidate.kind]} size="sm" color="secondary" />,
|
||||||
|
}))}
|
||||||
|
onChange={(artifactId) => onSelectArtifact?.(artifactId)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{artifact.name}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
centerContent={
|
||||||
|
artifact.kind === "markdown" ? (
|
||||||
|
<TabList
|
||||||
|
value={view}
|
||||||
|
onChange={(value) => setView(value as "edit" | "preview")}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Tab value="preview" label="Preview" />
|
||||||
|
<Tab value="edit" label="Editor" />
|
||||||
|
</TabList>
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{artifact.description}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
endContent={
|
||||||
|
editable && onSave ? (
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon icon={IconDeviceFloppy} size="sm" />}
|
||||||
|
isDisabled={!isDirty || isSubmitting}
|
||||||
|
onClick={save}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StackItem size="fill" className="artifact-viewer-surface">
|
||||||
|
{content}
|
||||||
|
</StackItem>
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
end={
|
||||||
|
<ArtifactReviewPanel
|
||||||
|
artifact={artifact}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onApprove={approve}
|
||||||
|
onRequestChanges={requestChanges}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Artifact } from "./types";
|
||||||
|
|
||||||
|
const MARKDOWN_CONTENT = `# Implementation notes
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The agent updated the payment flow and added validation around retryable failures.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added an idempotency key to payment requests.
|
||||||
|
- Mapped provider errors to retryable and terminal states.
|
||||||
|
- Added regression coverage for duplicate webhook delivery.
|
||||||
|
|
||||||
|
## Approval required
|
||||||
|
|
||||||
|
Approving this document allows the agent to continue with the rollout checklist and open the pull request.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TYPESCRIPT_CONTENT = `export async function processPayment(input: PaymentInput) {
|
||||||
|
const idempotencyKey = createIdempotencyKey(input.orderId);
|
||||||
|
|
||||||
|
const result = await paymentProvider.charge({
|
||||||
|
...input,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === "retryable_failure") {
|
||||||
|
await retryQueue.enqueue(input, { idempotencyKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const JSON_CONTENT = `{
|
||||||
|
"silentRefresh": true,
|
||||||
|
"paymentRetries": {
|
||||||
|
"maxAttempts": 4,
|
||||||
|
"backoff": "exponential"
|
||||||
|
},
|
||||||
|
"approvalRequired": true
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function getTaskArtifacts(taskId: string): Artifact[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "implementation-notes",
|
||||||
|
taskId,
|
||||||
|
name: "implementation-notes.md",
|
||||||
|
title: "Implementation notes",
|
||||||
|
description: "Agent-generated implementation summary awaiting review.",
|
||||||
|
kind: "markdown",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
content: MARKDOWN_CONTENT,
|
||||||
|
language: "markdown",
|
||||||
|
version: 3,
|
||||||
|
updatedAt: "Just now",
|
||||||
|
author: { id: "code-reviewer", name: "Code Reviewer" },
|
||||||
|
approvalStatus: "pending",
|
||||||
|
agentState: "waiting_for_approval",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-flow",
|
||||||
|
taskId,
|
||||||
|
name: "payment-flow.ts",
|
||||||
|
title: "Payment flow",
|
||||||
|
description: "Payment processing implementation generated by the coding agent.",
|
||||||
|
kind: "code",
|
||||||
|
mimeType: "text/typescript",
|
||||||
|
content: TYPESCRIPT_CONTENT,
|
||||||
|
language: "typescript",
|
||||||
|
version: 7,
|
||||||
|
updatedAt: "8 minutes ago",
|
||||||
|
author: { id: "fullstack-dev", name: "Fullstack Developer" },
|
||||||
|
approvalStatus: "approved",
|
||||||
|
agentState: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "runtime-config",
|
||||||
|
taskId,
|
||||||
|
name: "runtime-config.json",
|
||||||
|
title: "Runtime configuration",
|
||||||
|
description: "Generated runtime flags and retry configuration.",
|
||||||
|
kind: "code",
|
||||||
|
mimeType: "application/json",
|
||||||
|
content: JSON_CONTENT,
|
||||||
|
language: "json",
|
||||||
|
version: 2,
|
||||||
|
updatedAt: "12 minutes ago",
|
||||||
|
author: { id: "fullstack-dev", name: "Fullstack Developer" },
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
agentState: "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "architecture-diagram",
|
||||||
|
taskId,
|
||||||
|
name: "architecture-diagram.svg",
|
||||||
|
title: "Architecture diagram",
|
||||||
|
description: "Current task architecture and service boundaries.",
|
||||||
|
kind: "image",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
src: "/pf-background.svg",
|
||||||
|
version: 1,
|
||||||
|
updatedAt: "18 minutes ago",
|
||||||
|
author: { id: "planner", name: "Planner" },
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
agentState: "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review-packet",
|
||||||
|
taskId,
|
||||||
|
name: "review-packet.pdf",
|
||||||
|
title: "Review packet",
|
||||||
|
description: "PDF packet prepared for stakeholder review.",
|
||||||
|
kind: "pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
src: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
|
||||||
|
version: 1,
|
||||||
|
updatedAt: "24 minutes ago",
|
||||||
|
author: { id: "code-reviewer", name: "Code Reviewer" },
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
agentState: "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-recording",
|
||||||
|
taskId,
|
||||||
|
name: "demo-recording.mp4",
|
||||||
|
title: "Demo recording",
|
||||||
|
description: "A short generated walkthrough of the completed flow.",
|
||||||
|
kind: "video",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
src: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||||
|
version: 1,
|
||||||
|
updatedAt: "31 minutes ago",
|
||||||
|
author: { id: "planner", name: "Planner" },
|
||||||
|
approvalStatus: "not_required",
|
||||||
|
agentState: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getTaskArtifacts } from "./artifact-fixtures";
|
||||||
|
import { artifactKindFromMimeType, isEditableArtifact } from "./types";
|
||||||
|
|
||||||
|
describe("artifact types", () => {
|
||||||
|
it.each([
|
||||||
|
["application/pdf", "pdf"],
|
||||||
|
["image/png", "image"],
|
||||||
|
["video/mp4", "video"],
|
||||||
|
["text/markdown", "markdown"],
|
||||||
|
["text/typescript", "code"],
|
||||||
|
] as const)("maps %s to %s", (mimeType, expectedKind) => {
|
||||||
|
expect(artifactKindFromMimeType(mimeType)).toBe(expectedKind);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only enables editing for code and markdown", () => {
|
||||||
|
const artifacts = getTaskArtifacts("TASK-1");
|
||||||
|
const editableKinds = artifacts.filter(isEditableArtifact).map((artifact) => artifact.kind);
|
||||||
|
|
||||||
|
expect(editableKinds).toEqual(["markdown", "code", "code"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates isolated task-scoped artifact records", () => {
|
||||||
|
const first = getTaskArtifacts("TASK-1");
|
||||||
|
const second = getTaskArtifacts("TASK-2");
|
||||||
|
|
||||||
|
expect(first).toHaveLength(6);
|
||||||
|
expect(first.every((artifact) => artifact.taskId === "TASK-1")).toBe(true);
|
||||||
|
expect(second.every((artifact) => artifact.taskId === "TASK-2")).toBe(true);
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type ArtifactKind = "pdf" | "image" | "video" | "markdown" | "code";
|
||||||
|
|
||||||
|
export type ArtifactApprovalStatus = "not_required" | "pending" | "approved" | "changes_requested";
|
||||||
|
|
||||||
|
export type ArtifactAgentState = "waiting_for_approval" | "running" | "completed";
|
||||||
|
|
||||||
|
export interface ArtifactAuthor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactReviewFeedback {
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
kind: ArtifactKind;
|
||||||
|
mimeType: string;
|
||||||
|
src?: string;
|
||||||
|
content?: string;
|
||||||
|
language?: string;
|
||||||
|
version: number;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: ArtifactAuthor;
|
||||||
|
approvalStatus: ArtifactApprovalStatus;
|
||||||
|
agentState: ArtifactAgentState;
|
||||||
|
reviewFeedback?: ArtifactReviewFeedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactViewerCallbacks {
|
||||||
|
onContentChange?: (artifactId: string, content: string) => void;
|
||||||
|
onSave?: (artifact: Artifact) => void | Promise<void>;
|
||||||
|
onApprove?: (artifact: Artifact, feedback: ArtifactReviewFeedback) => void | Promise<void>;
|
||||||
|
onRequestChanges?: (artifact: Artifact, feedback: ArtifactReviewFeedback) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEditableArtifact(artifact: Artifact): boolean {
|
||||||
|
return artifact.kind === "code" || artifact.kind === "markdown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artifactKindFromMimeType(mimeType: string): ArtifactKind {
|
||||||
|
if (mimeType === "application/pdf") return "pdf";
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
if (mimeType === "text/markdown") return "markdown";
|
||||||
|
return "code";
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Center } from "@astryxdesign/core/Center";
|
||||||
|
import { Layout, LayoutContent, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { getTaskArtifacts } from "../lib/artifact-fixtures";
|
||||||
|
import { ArtifactViewer } from "../components/ArtifactViewer";
|
||||||
|
import type { Artifact } from "../lib/types";
|
||||||
|
|
||||||
|
export function ArtifactViewerPage() {
|
||||||
|
const { taskId = "", artifactId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [artifacts, setArtifacts] = useState<Artifact[]>(() => getTaskArtifacts(taskId));
|
||||||
|
const artifact = artifacts.find((candidate) => candidate.id === artifactId);
|
||||||
|
|
||||||
|
const updateArtifact = (artifactIdToUpdate: string, update: Partial<Artifact>) => {
|
||||||
|
setArtifacts((current) =>
|
||||||
|
current.map((candidate) =>
|
||||||
|
candidate.id === artifactIdToUpdate ? { ...candidate, ...update } : candidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!artifact) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
content={
|
||||||
|
<LayoutContent role="main">
|
||||||
|
<Center axis="both" height="100%">
|
||||||
|
<VStack gap={3} hAlign="center">
|
||||||
|
<Heading level={1}>Artifact not found</Heading>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
This artifact is unavailable or no longer belongs to the task.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label="Back to task"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate(`/tasks/${taskId}`)}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArtifactViewer
|
||||||
|
artifact={artifact}
|
||||||
|
artifacts={artifacts}
|
||||||
|
onBack={() => navigate(`/tasks/${taskId}`)}
|
||||||
|
onSelectArtifact={(nextArtifactId) =>
|
||||||
|
navigate(`/tasks/${taskId}/artifacts/${nextArtifactId}`)
|
||||||
|
}
|
||||||
|
onContentChange={() => {}}
|
||||||
|
onSave={(savedArtifact) => {
|
||||||
|
updateArtifact(savedArtifact.id, {
|
||||||
|
content: savedArtifact.content,
|
||||||
|
version: savedArtifact.version + 1,
|
||||||
|
updatedAt: "Just now",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onApprove={(approvedArtifact, feedback) => {
|
||||||
|
updateArtifact(approvedArtifact.id, {
|
||||||
|
content: approvedArtifact.content,
|
||||||
|
approvalStatus: "approved",
|
||||||
|
agentState: "running",
|
||||||
|
reviewFeedback: feedback,
|
||||||
|
updatedAt: "Just now",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRequestChanges={(reviewedArtifact, feedback) => {
|
||||||
|
updateArtifact(reviewedArtifact.id, {
|
||||||
|
content: reviewedArtifact.content,
|
||||||
|
approvalStatus: "changes_requested",
|
||||||
|
agentState: "running",
|
||||||
|
reviewFeedback: feedback,
|
||||||
|
updatedAt: "Just now",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Center, Grid, Heading, HStack, Link, Section, Text, VStack } from "@astryxdesign/core";
|
||||||
|
import { APP_NAME } from "../../../static/app-data.tsx";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
page: {
|
||||||
|
boxSizing: "border-box",
|
||||||
|
minHeight: "100dvh",
|
||||||
|
backgroundColor: "var(--color-background-body)",
|
||||||
|
backgroundImage: {
|
||||||
|
default: 'url("/pf-background.svg")',
|
||||||
|
"@media (max-width: 599px)": "none",
|
||||||
|
},
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundPosition: "right bottom",
|
||||||
|
backgroundSize: "auto 58%",
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
overflow: {
|
||||||
|
default: "hidden",
|
||||||
|
"@media (max-width: 599px)": "visible",
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
default: "var(--color-background-surface)",
|
||||||
|
"@media (max-width: 599px)": "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
minHeight: "100dvh",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: null,
|
||||||
|
"@media (max-width: 599px)": "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: {
|
||||||
|
default: "block",
|
||||||
|
"@media (max-width: 599px)": "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storyImage: {
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
objectPosition: "center",
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
formPanel: {
|
||||||
|
minHeight: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
padding: {
|
||||||
|
default: "var(--spacing-10)",
|
||||||
|
"@media (max-width: 599px)": "var(--spacing-4)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type AuthLayoutProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
alternatePrompt: string;
|
||||||
|
alternateHref: string;
|
||||||
|
alternateLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthLayout({
|
||||||
|
children,
|
||||||
|
heading,
|
||||||
|
description,
|
||||||
|
alternatePrompt,
|
||||||
|
alternateHref,
|
||||||
|
alternateLabel,
|
||||||
|
}: AuthLayoutProps) {
|
||||||
|
return (
|
||||||
|
<Center axis="both" width="100%" xstyle={styles.page}>
|
||||||
|
<Section variant="transparent" padding={0} width="100%" xstyle={styles.shell}>
|
||||||
|
<Grid
|
||||||
|
columns={{ minWidth: 280, repeat: "fit" }}
|
||||||
|
gap={0}
|
||||||
|
align="stretch"
|
||||||
|
xstyle={styles.grid}
|
||||||
|
>
|
||||||
|
<Section variant="transparent" padding={0} height="100%" xstyle={styles.story}>
|
||||||
|
<picture>
|
||||||
|
<source type="image/webp" srcSet="/building.webp" />
|
||||||
|
<img
|
||||||
|
src="/building.jpg"
|
||||||
|
alt="Modern building exterior"
|
||||||
|
{...stylex.props(styles.storyImage)}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section variant="transparent" padding={0} height="100%" xstyle={styles.formPanel}>
|
||||||
|
<Center axis="both" width="100%" height="100%">
|
||||||
|
<VStack gap={6} width="100%" maxWidth={460}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
alt={`${APP_NAME} logo`}
|
||||||
|
className={`theme-logo ${stylex.props(styles.logo).className}`}
|
||||||
|
/>
|
||||||
|
<Text type="body" size="lg" weight="bold">
|
||||||
|
{APP_NAME}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Heading level={1} type="display-2" textWrap="balance">
|
||||||
|
{heading}
|
||||||
|
</Heading>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<VStack gap={3} hAlign="center" width="100%">
|
||||||
|
<Text type="body" size="sm" color="secondary" justify="center">
|
||||||
|
{alternatePrompt}{" "}
|
||||||
|
<Link href={alternateHref} hasUnderline>
|
||||||
|
{alternateLabel}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary" justify="center">
|
||||||
|
By continuing, you agree to our{" "}
|
||||||
|
<Link
|
||||||
|
href="https://anthaathi.com/legal/terms-of-service/"
|
||||||
|
type="supporting"
|
||||||
|
isExternalLink
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="https://anthaathi.com/legal/privacy-policy/"
|
||||||
|
type="supporting"
|
||||||
|
isExternalLink
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</Section>
|
||||||
|
</Grid>
|
||||||
|
</Section>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { Item } from "@astryxdesign/core/Item";
|
||||||
|
import { Popover } from "@astryxdesign/core/Popover";
|
||||||
|
import { VStack } from "@astryxdesign/core/Stack";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { ToggleButton, ToggleButtonGroup } from "@astryxdesign/core/ToggleButton";
|
||||||
|
import { colorVars, radiusVars, spacingVars } from "@astryxdesign/core/theme/tokens.stylex";
|
||||||
|
import {
|
||||||
|
IconChevronUp,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconLogout,
|
||||||
|
IconMoon,
|
||||||
|
IconSettings,
|
||||||
|
IconSun,
|
||||||
|
IconUserCircle,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { authClient } from "../../../lib/auth-client";
|
||||||
|
import { useThemeMode } from "../../../lib/theme-mode";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
popover: {
|
||||||
|
borderRadius: radiusVars["--radius-page"],
|
||||||
|
},
|
||||||
|
profileSummary: {
|
||||||
|
backgroundColor: colorVars["--color-background-muted"],
|
||||||
|
borderRadius: radiusVars["--radius-container"],
|
||||||
|
},
|
||||||
|
profileTrigger: {
|
||||||
|
width: "100%",
|
||||||
|
marginBlockEnd: `calc(-1 * ${spacingVars["--spacing-2"]})`,
|
||||||
|
},
|
||||||
|
themeGroup: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Account panel anchored in the bottom-left SideNav footer. */
|
||||||
|
export function UserProfileMenu() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const user = session?.user ?? null;
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const firstName = user.name.trim().split(/\s+/)[0] || "there";
|
||||||
|
|
||||||
|
const handleProfileSettings = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate("/settings/profile");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrganizationSettings = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate("/settings/organization");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (nextMode: string | null) => {
|
||||||
|
if (nextMode === "light" || nextMode === "dark" || nextMode === "system") {
|
||||||
|
setMode(nextMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
await authClient.signOut();
|
||||||
|
navigate("/auth/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<VStack gap={2} padding={3} hAlign="center" xstyle={styles.profileSummary}>
|
||||||
|
<Avatar
|
||||||
|
src={user.image ?? undefined}
|
||||||
|
size="lg"
|
||||||
|
alt={user.name}
|
||||||
|
name={user.name}
|
||||||
|
tooltip={false}
|
||||||
|
/>
|
||||||
|
<VStack gap={0} hAlign="center">
|
||||||
|
<Text type="large" maxLines={1} justify="center">
|
||||||
|
Hi, {firstName}!
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1} justify="center">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Item
|
||||||
|
density="balanced"
|
||||||
|
startContent={<Icon icon={IconUserCircle} size="sm" color="secondary" />}
|
||||||
|
label="Profile settings"
|
||||||
|
onClick={handleProfileSettings}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
density="balanced"
|
||||||
|
startContent={<Icon icon={IconSettings} size="sm" color="secondary" />}
|
||||||
|
label="Organization settings"
|
||||||
|
onClick={handleOrganizationSettings}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<VStack gap={2} paddingInline={2}>
|
||||||
|
<Text type="label">Appearance</Text>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
type="single"
|
||||||
|
value={mode}
|
||||||
|
onChange={handleThemeChange}
|
||||||
|
label="Appearance theme"
|
||||||
|
size="sm"
|
||||||
|
xstyle={styles.themeGroup}
|
||||||
|
>
|
||||||
|
<ToggleButton value="light" label="Light theme" icon={<Icon icon={IconSun} size="sm" />}>
|
||||||
|
Light
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="dark" label="Dark theme" icon={<Icon icon={IconMoon} size="sm" />}>
|
||||||
|
Dark
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton
|
||||||
|
value="system"
|
||||||
|
label="System theme"
|
||||||
|
icon={<Icon icon={IconDeviceDesktop} size="sm" />}
|
||||||
|
>
|
||||||
|
System
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Item
|
||||||
|
density="balanced"
|
||||||
|
startContent={<Icon icon={IconLogout} size="sm" color="secondary" />}
|
||||||
|
label="Sign out"
|
||||||
|
onClick={() => void handleSignOut()}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
label={`Account menu for ${user.name}`}
|
||||||
|
content={panel}
|
||||||
|
placement="above"
|
||||||
|
alignment="start"
|
||||||
|
width={360}
|
||||||
|
xstyle={styles.popover}
|
||||||
|
>
|
||||||
|
{({ ref, onClick, ...triggerProps }) => (
|
||||||
|
<Item
|
||||||
|
{...triggerProps}
|
||||||
|
ref={ref}
|
||||||
|
density="spacious"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
src={user.image ?? undefined}
|
||||||
|
size="md"
|
||||||
|
alt={user.name}
|
||||||
|
name={user.name}
|
||||||
|
tooltip={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={user.name}
|
||||||
|
description={user.email}
|
||||||
|
labelLines={1}
|
||||||
|
descriptionLines={1}
|
||||||
|
endContent={<Icon icon={IconChevronUp} size="sm" color="secondary" />}
|
||||||
|
onClick={onClick}
|
||||||
|
xstyle={styles.profileTrigger}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
|
||||||
|
export const authStyles = stylex.create({
|
||||||
|
form: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
socialGrid: {
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: null,
|
||||||
|
"@media (max-width: 359px)": "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { safeReturnTo } from "../../../lib/navigation/safe-return-to";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the validated `?returnTo=` query param (set by RequireAuth).
|
||||||
|
* Defaults to `/` when absent or unsafe.
|
||||||
|
*/
|
||||||
|
export function useReturnTo(): string {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
return safeReturnTo(searchParams.get("returnTo"), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alternate link that preserves the return path across login ⇄ signup. */
|
||||||
|
export function alternateHref(returnTo: string, target: "login" | "signup"): string {
|
||||||
|
return `/auth/${target}?returnTo=${encodeURIComponent(returnTo)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { useToast } from "@astryxdesign/core/Toast";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { IconBrandGithub, IconBrandGoogle, IconLock, IconMail } from "@tabler/icons-react";
|
||||||
|
import { AuthLayout } from "../components/AuthLayout";
|
||||||
|
import { authStyles } from "../lib/authStyles";
|
||||||
|
import { alternateHref, useReturnTo } from "../lib/return-to";
|
||||||
|
import { authClient } from "../../../lib/auth-client";
|
||||||
|
import { emailSchema, fieldErrorState, passwordSchema } from "../../../lib/validation";
|
||||||
|
import { loginSchema } from "../schemas/auth.schema";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const returnTo = useReturnTo();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { email: "", password: "" },
|
||||||
|
validators: { onSubmit: loginSchema },
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
setServerError(null);
|
||||||
|
try {
|
||||||
|
const { error: signInError } = await authClient.signIn.email(value);
|
||||||
|
if (signInError) {
|
||||||
|
setServerError(signInError.message ?? "Sign-in failed. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sign-in failed:", err);
|
||||||
|
showToast({
|
||||||
|
body: "Couldn't reach the server. Please check your connection and try again.",
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "sign-in-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitted = useStore(form.store, (state) => state.isSubmitted);
|
||||||
|
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||||
|
|
||||||
|
const handleSocial = async (provider: "google" | "github") => {
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
|
provider,
|
||||||
|
callbackURL: returnTo,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
showToast({
|
||||||
|
body: error.message ?? `Couldn't sign in with ${provider}. Please try again.`,
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "social-sign-in-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Social sign-in with ${provider} failed:`, err);
|
||||||
|
showToast({
|
||||||
|
body: "Couldn't reach the sign-in service. Please try again.",
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "social-sign-in-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
heading="Welcome back"
|
||||||
|
description="Sign in to pick up exactly where you left off."
|
||||||
|
alternatePrompt="New to Attendance?"
|
||||||
|
alternateHref={alternateHref(returnTo, "signup")}
|
||||||
|
alternateLabel="Create an account"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
{...stylex.props(authStyles.form)}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void form.handleSubmit();
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<VStack gap={5} hAlign="stretch" width="100%">
|
||||||
|
<Grid columns={2} gap={3} justify="stretch" xstyle={authStyles.socialGrid}>
|
||||||
|
<Button
|
||||||
|
label="Google"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconBrandGoogle} size="sm" />}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSocial("google")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="GitHub"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconBrandGithub} size="sm" />}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSocial("github")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider label="or continue with email" />
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
<form.Field name="email" validators={{ onBlur: emailSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => field.handleChange(value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
hasAutoFocus
|
||||||
|
startIcon={IconMail}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="password" validators={{ onBlur: passwordSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { message } = fieldErrorState(field.state.meta, isSubmitted, serverError);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
type="password"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError(null);
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconLock}
|
||||||
|
status={message ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isSubmitting ? "Signing in…" : "Sign in"}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Skeleton } from "@astryxdesign/core/Skeleton";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { useToast } from "@astryxdesign/core/Toast";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconBriefcase, IconLink, IconPhone, IconUser } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { authClient } from "../../../lib/auth-client";
|
||||||
|
import {
|
||||||
|
isSupportedProfileImageSource,
|
||||||
|
PROFILE_IMAGE_MAX_SOURCE_LENGTH,
|
||||||
|
} from "../../../lib/profile-image";
|
||||||
|
import { fieldErrorState } from "../../../lib/validation";
|
||||||
|
import { authStyles } from "../lib/authStyles";
|
||||||
|
import { firstNameSchema, lastNameSchema } from "../schemas/auth.schema";
|
||||||
|
|
||||||
|
const jobTitleSchema = z.string().trim().max(80, "Job title must be 80 characters or fewer");
|
||||||
|
const phoneSchema = z.string().trim().max(30, "Phone number must be 30 characters or fewer");
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
firstName: firstNameSchema,
|
||||||
|
lastName: lastNameSchema,
|
||||||
|
jobTitle: jobTitleSchema,
|
||||||
|
phone: phoneSchema,
|
||||||
|
imageUrl: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(PROFILE_IMAGE_MAX_SOURCE_LENGTH, "Profile image is too large")
|
||||||
|
.refine(isSupportedProfileImageSource, {
|
||||||
|
message: "Enter a valid HTTP or HTTPS image URL",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProfileUser = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image?: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
user: ProfileUser;
|
||||||
|
onUpdated: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileForm({ user, onUpdated }: ProfileFormProps) {
|
||||||
|
const showToast = useToast();
|
||||||
|
const [serverError, setServerError] = useState("");
|
||||||
|
const fallbackName = user.name.trim().split(/\s+/);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
firstName: user.firstName || fallbackName[0] || "",
|
||||||
|
lastName: user.lastName || fallbackName.slice(1).join(" "),
|
||||||
|
jobTitle: user.jobTitle ?? "",
|
||||||
|
phone: user.phone ?? "",
|
||||||
|
imageUrl: user.image ?? "",
|
||||||
|
},
|
||||||
|
validators: { onSubmit: profileSchema },
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
setServerError("");
|
||||||
|
try {
|
||||||
|
const firstName = value.firstName.trim();
|
||||||
|
const lastName = value.lastName.trim();
|
||||||
|
const { error } = await authClient.updateUser({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
name: `${firstName} ${lastName}`,
|
||||||
|
jobTitle: value.jobTitle.trim(),
|
||||||
|
phone: value.phone.trim(),
|
||||||
|
image: value.imageUrl.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setServerError(error.message ?? "Couldn't update your profile.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onUpdated();
|
||||||
|
showToast({
|
||||||
|
body: "Profile updated.",
|
||||||
|
type: "info",
|
||||||
|
uniqueID: "profile-updated",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setServerError("Couldn't reach the server. Please try again.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitted = useStore(form.store, (state) => state.isSubmitted);
|
||||||
|
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||||
|
const imageUrl = useStore(form.store, (state) => state.values.imageUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={8} width="100%" maxWidth={640}>
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={2}>Profile settings</Heading>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
Manage the personal details shown across your account.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||||
|
<Avatar
|
||||||
|
src={imageUrl.trim() || undefined}
|
||||||
|
size="lg"
|
||||||
|
alt={user.name}
|
||||||
|
name={user.name}
|
||||||
|
tooltip={false}
|
||||||
|
/>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{user.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<form
|
||||||
|
{...stylex.props(authStyles.form)}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void form.handleSubmit();
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<VStack gap={5} width="100%">
|
||||||
|
<Grid columns={{ minWidth: 220, repeat: "fit" }} gap={4}>
|
||||||
|
<form.Field name="firstName" validators={{ onBlur: firstNameSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="First name"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconUser}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="lastName" validators={{ onBlur: lastNameSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Last name"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconUser}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid columns={{ minWidth: 220, repeat: "fit" }} gap={4}>
|
||||||
|
<form.Field name="jobTitle" validators={{ onBlur: jobTitleSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Job title"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
placeholder="Operations manager"
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isOptional
|
||||||
|
startIcon={IconBriefcase}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="phone" validators={{ onBlur: phoneSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Phone"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
placeholder="+91 98765 43210"
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isOptional
|
||||||
|
startIcon={IconPhone}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<form.Field name="imageUrl" validators={{ onBlur: profileSchema.shape.imageUrl }}>
|
||||||
|
{(field) => {
|
||||||
|
const { message } = fieldErrorState(field.state.meta, isSubmitted, serverError);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Profile image URL"
|
||||||
|
description="Use a public HTTPS image URL. Leave blank to use your initials."
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isOptional
|
||||||
|
startIcon={IconLink}
|
||||||
|
status={message ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<HStack hAlign="end">
|
||||||
|
<Button
|
||||||
|
label={isSubmitting ? "Saving…" : "Save profile"}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSettingsPage() {
|
||||||
|
const { data: session, isPending, refetch } = authClient.useSession();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<VStack gap={4} width="100%" maxWidth={640}>
|
||||||
|
<Skeleton width={240} height={32} radius={2} index={0} />
|
||||||
|
<Skeleton width="100%" height={260} radius={3} index={1} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) return null;
|
||||||
|
|
||||||
|
return <ProfileForm user={session.user} onUpdated={refetch} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Divider } from "@astryxdesign/core/Divider";
|
||||||
|
import { Grid } from "@astryxdesign/core/Grid";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import { useToast } from "@astryxdesign/core/Toast";
|
||||||
|
import { VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import {
|
||||||
|
IconAt,
|
||||||
|
IconBrandGithub,
|
||||||
|
IconBrandGoogle,
|
||||||
|
IconLock,
|
||||||
|
IconMail,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { AuthLayout } from "../components/AuthLayout";
|
||||||
|
import { authStyles } from "../lib/authStyles";
|
||||||
|
import { alternateHref, useReturnTo } from "../lib/return-to";
|
||||||
|
import { authClient } from "../../../lib/auth-client";
|
||||||
|
import { emailSchema, fieldErrorState, passwordSchema } from "../../../lib/validation";
|
||||||
|
import {
|
||||||
|
firstNameSchema,
|
||||||
|
lastNameSchema,
|
||||||
|
signupSchema,
|
||||||
|
usernameSchema,
|
||||||
|
} from "../schemas/auth.schema";
|
||||||
|
|
||||||
|
type ServerError = { message: string; field?: string };
|
||||||
|
|
||||||
|
function errorField(code: string | undefined): string | undefined {
|
||||||
|
switch (code) {
|
||||||
|
case "USER_ALREADY_EXISTS":
|
||||||
|
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
|
||||||
|
return "email";
|
||||||
|
case "USERNAME_IS_ALREADY_TAKEN":
|
||||||
|
case "USERNAME_TOO_SHORT":
|
||||||
|
case "USERNAME_TOO_LONG":
|
||||||
|
case "INVALID_USERNAME":
|
||||||
|
return "username";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const [serverError, setServerError] = useState<ServerError | null>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const returnTo = useReturnTo();
|
||||||
|
const clearServerError = () => setServerError(null);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
},
|
||||||
|
validators: { onSubmit: signupSchema },
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
clearServerError();
|
||||||
|
try {
|
||||||
|
const { error: signUpError } = await authClient.signUp.email({
|
||||||
|
firstName: value.firstName,
|
||||||
|
lastName: value.lastName,
|
||||||
|
// Better Auth's core user model requires `name` — store the full
|
||||||
|
// name there for backward compat (UI, avatars, etc.).
|
||||||
|
name: [value.firstName, value.lastName].filter(Boolean).join(" "),
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
username: value.username || undefined,
|
||||||
|
});
|
||||||
|
if (signUpError) {
|
||||||
|
setServerError({
|
||||||
|
message: signUpError.message ?? "Sign-up failed. Try again.",
|
||||||
|
field: errorField(signUpError.code),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sign-up failed:", err);
|
||||||
|
showToast({
|
||||||
|
body: "Couldn't reach the server. Please check your connection and try again.",
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "sign-up-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitted = useStore(form.store, (state) => state.isSubmitted);
|
||||||
|
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
|
||||||
|
|
||||||
|
const handleSocial = async (provider: "google" | "github") => {
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
|
provider,
|
||||||
|
callbackURL: returnTo,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
showToast({
|
||||||
|
body: error.message ?? `Couldn't sign in with ${provider}. Please try again.`,
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "social-sign-in-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Social sign-in with ${provider} failed:`, err);
|
||||||
|
showToast({
|
||||||
|
body: "Couldn't reach the sign-in service. Please try again.",
|
||||||
|
type: "error",
|
||||||
|
uniqueID: "social-sign-in-error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
heading="Create your account"
|
||||||
|
description="A few details and your workspace is ready to go."
|
||||||
|
alternatePrompt="Already have an account?"
|
||||||
|
alternateHref={alternateHref(returnTo, "login")}
|
||||||
|
alternateLabel="Sign in"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
{...stylex.props(authStyles.form)}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void form.handleSubmit();
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<VStack gap={5} hAlign="stretch" width="100%">
|
||||||
|
<Grid columns={2} gap={3} justify="stretch" xstyle={authStyles.socialGrid}>
|
||||||
|
<Button
|
||||||
|
label="Google"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconBrandGoogle} size="sm" />}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSocial("google")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="GitHub"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconBrandGithub} size="sm" />}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSocial("github")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider label="or sign up with email" />
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Grid columns={{ minWidth: 190, repeat: "fit" }} gap={4}>
|
||||||
|
<form.Field name="firstName" validators={{ onBlur: firstNameSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="First name"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
hasAutoFocus
|
||||||
|
startIcon={IconUser}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="lastName" validators={{ onBlur: lastNameSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Last name"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconUser}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<form.Field name="email" validators={{ onBlur: emailSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { message } = fieldErrorState(
|
||||||
|
field.state.meta,
|
||||||
|
isSubmitted,
|
||||||
|
serverError?.field === "email" ? serverError.message : null,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconMail}
|
||||||
|
status={message ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="username" validators={{ onBlur: usernameSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { message } = fieldErrorState(
|
||||||
|
field.state.meta,
|
||||||
|
isSubmitted,
|
||||||
|
serverError?.field === "username" ? serverError.message : null,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isOptional
|
||||||
|
startIcon={IconAt}
|
||||||
|
status={message ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="password" validators={{ onBlur: passwordSchema }}>
|
||||||
|
{(field) => {
|
||||||
|
const { message } = fieldErrorState(
|
||||||
|
field.state.meta,
|
||||||
|
isSubmitted,
|
||||||
|
serverError && !serverError.field ? serverError.message : null,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconLock}
|
||||||
|
status={message ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="confirmPassword">
|
||||||
|
{(field) => {
|
||||||
|
const { showError, message } = fieldErrorState(field.state.meta, isSubmitted);
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
field.handleChange(value);
|
||||||
|
clearServerError();
|
||||||
|
}}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
size="lg"
|
||||||
|
width="100%"
|
||||||
|
isRequired
|
||||||
|
startIcon={IconLock}
|
||||||
|
status={showError ? { type: "error", message } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isSubmitting ? "Creating account…" : "Create account"}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { emailSchema, passwordSchema } from "../../../lib/validation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth form schemas — validated with TanStack Form
|
||||||
|
* (`@tanstack/react-form` + `zodValidator` from `@tanstack/zod-form-adapter`).
|
||||||
|
*
|
||||||
|
* Rules mirror the server-side config in `apps/worker/src/lib/auth/auth-options.ts`
|
||||||
|
* (email/password + username plugin):
|
||||||
|
* - password minimum 8 chars (`minPasswordLength: 8`)
|
||||||
|
* - username 3–30 chars, alphanumeric + underscores (username plugin default)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const firstNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "First name is required")
|
||||||
|
.max(50, "First name is too long");
|
||||||
|
|
||||||
|
export const lastNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Last name is required")
|
||||||
|
.max(50, "Last name is too long");
|
||||||
|
|
||||||
|
export const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === "" || /^[a-zA-Z0-9_]{3,30}$/.test(value), {
|
||||||
|
message: "3–30 characters — letters, numbers and underscores only",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signupSchema = z
|
||||||
|
.object({
|
||||||
|
firstName: firstNameSchema,
|
||||||
|
lastName: lastNameSchema,
|
||||||
|
email: emailSchema,
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SignupInput = z.infer<typeof signupSchema>;
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Center } from "@astryxdesign/core/Center";
|
||||||
|
import { EmptyState } from "@astryxdesign/core/EmptyState";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import { Layout, LayoutContent } from "@astryxdesign/core/Layout";
|
||||||
|
import { IconMessageChatbot } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export function ChatNotFound() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
content={
|
||||||
|
<LayoutContent role="main">
|
||||||
|
<Center axis="both" height="100%">
|
||||||
|
<EmptyState
|
||||||
|
title="Session not found"
|
||||||
|
description="This chat session is unavailable or was removed."
|
||||||
|
icon={<Icon icon={IconMessageChatbot} size="lg" color="secondary" />}
|
||||||
|
actions={
|
||||||
|
<Button label="Back to chat" variant="primary" onClick={() => navigate("/chat")} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import {
|
||||||
|
ChatMessage,
|
||||||
|
ChatMessageBubble,
|
||||||
|
ChatMessageList,
|
||||||
|
ChatMessageMetadata,
|
||||||
|
ChatSystemMessage,
|
||||||
|
ChatToolCalls,
|
||||||
|
} from "@astryxdesign/core/Chat";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import type { AgentRow } from "../../agent-backends/lib/agent-rows";
|
||||||
|
import type { TaskRow } from "../../tasks/lib/task-types";
|
||||||
|
import type { ChatSession } from "../lib/chat-sessions";
|
||||||
|
import type { ChatMessageRecord } from "../lib/chat-types";
|
||||||
|
|
||||||
|
interface MessageThreadProps {
|
||||||
|
agent: AgentRow;
|
||||||
|
session: ChatSession;
|
||||||
|
task?: TaskRow;
|
||||||
|
messages: ChatMessageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageThread({ agent, session, task, messages }: MessageThreadProps) {
|
||||||
|
return (
|
||||||
|
<ChatMessageList
|
||||||
|
density="balanced"
|
||||||
|
style={{ width: "100%", maxWidth: 960, marginInline: "auto" }}
|
||||||
|
>
|
||||||
|
<ChatSystemMessage variant="divider">
|
||||||
|
{task ? `Working on ${task.taskId}` : session.title}
|
||||||
|
</ChatSystemMessage>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={message.id}
|
||||||
|
sender={message.sender}
|
||||||
|
avatar={
|
||||||
|
message.sender === "assistant" ? (
|
||||||
|
<Avatar src={agent.imageUrl} name={agent.name} alt={agent.name} size="sm" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.toolCalls?.length ? (
|
||||||
|
<ChatToolCalls defaultIsExpanded calls={message.toolCalls} />
|
||||||
|
) : null}
|
||||||
|
<ChatMessageBubble
|
||||||
|
name={
|
||||||
|
message.sender === "assistant" ? (
|
||||||
|
<Text type="supporting" weight="semibold" color="secondary">
|
||||||
|
{agent.name}
|
||||||
|
</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
metadata={
|
||||||
|
<ChatMessageMetadata
|
||||||
|
timestamp={message.timestamp}
|
||||||
|
status={message.sender === "user" ? "delivered" : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.body}
|
||||||
|
</ChatMessageBubble>
|
||||||
|
</ChatMessage>
|
||||||
|
))}
|
||||||
|
</ChatMessageList>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { pixel, proportional } from "@astryxdesign/core/Table";
|
||||||
|
import type { TableColumn } from "@astryxdesign/core/Table";
|
||||||
|
import { Text } from "@astryxdesign/core/Text";
|
||||||
|
import { chatSessionStatusLabel, type ChatSession } from "../lib/chat-sessions";
|
||||||
|
import { sessionAgent, STATUS_DOT_VARIANT } from "../lib/chat-sessions-ui";
|
||||||
|
|
||||||
|
export const columns: TableColumn<ChatSession>[] = [
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
header: "Agent",
|
||||||
|
width: proportional(2),
|
||||||
|
renderCell: (session) => {
|
||||||
|
const agent = sessionAgent(session);
|
||||||
|
return (
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
{agent ? (
|
||||||
|
<Avatar
|
||||||
|
src={agent.imageUrl}
|
||||||
|
name={agent.name}
|
||||||
|
alt={agent.name}
|
||||||
|
size="sm"
|
||||||
|
tooltip={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Text type="body" weight="semibold">
|
||||||
|
{agent?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "session",
|
||||||
|
header: "Session",
|
||||||
|
width: proportional(3),
|
||||||
|
renderCell: (session) => (
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" maxLines={1}>
|
||||||
|
{session.title}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1}>
|
||||||
|
{session.lastMessage}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "task",
|
||||||
|
header: "Task",
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (session) =>
|
||||||
|
session.taskId ? (
|
||||||
|
<Text type="body" maxLines={1}>
|
||||||
|
{session.taskId}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updated",
|
||||||
|
header: "Updated",
|
||||||
|
width: pixel(120),
|
||||||
|
renderCell: (session) => (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{session.updatedAt}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: pixel(110),
|
||||||
|
renderCell: (session) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StatusDot
|
||||||
|
variant={STATUS_DOT_VARIANT[session.status]}
|
||||||
|
label={chatSessionStatusLabel(session.status)}
|
||||||
|
/>
|
||||||
|
<Text type="supporting">{chatSessionStatusLabel(session.status)}</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "unread",
|
||||||
|
header: "Unread",
|
||||||
|
width: pixel(80),
|
||||||
|
renderCell: (session) =>
|
||||||
|
session.unreadCount > 0 ? (
|
||||||
|
<Badge variant="blue" label={String(session.unreadCount)} />
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
|
import { Avatar } from "@astryxdesign/core/Avatar";
|
||||||
|
import { BreadcrumbItem, Breadcrumbs } from "@astryxdesign/core/Breadcrumbs";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { ChatComposer, ChatLayout } from "@astryxdesign/core/Chat";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutHeader,
|
||||||
|
LayoutPanel,
|
||||||
|
StackItem,
|
||||||
|
VStack,
|
||||||
|
} from "@astryxdesign/core/Layout";
|
||||||
|
import { ResizeHandle, useResizable } from "@astryxdesign/core/Resizable";
|
||||||
|
import { StatusDot } from "@astryxdesign/core/StatusDot";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { IconLayoutSidebarRightExpand } from "@tabler/icons-react";
|
||||||
|
import { BackButton } from "../../../components/BackButton";
|
||||||
|
import { agentRows, type AgentRow } from "../../agent-backends/lib/agent-rows";
|
||||||
|
import { backendLabel } from "../../agent-backends/lib/backend-label";
|
||||||
|
import { getChatSession, type ChatSession } from "../lib/chat-sessions";
|
||||||
|
import { getProject } from "../../projects/lib/project-fixtures";
|
||||||
|
import { WorkspaceToolsPanel } from "../../conversations/components/WorkspaceToolsPanel";
|
||||||
|
import { allTasks } from "../../tasks/lib/task-data";
|
||||||
|
import { ChatNotFound } from "../components/ChatNotFound";
|
||||||
|
import { MessageThread } from "../components/MessageThread";
|
||||||
|
import { initialMessages } from "../lib/chat-messages";
|
||||||
|
import { styles } from "../lib/chat-styles";
|
||||||
|
import type { ChatMessageRecord } from "../lib/chat-types";
|
||||||
|
|
||||||
|
/** Duration of the workspace tools panel width transition (matches --duration-medium). */
|
||||||
|
const TOOLS_PANEL_TRANSITION_MS = 410;
|
||||||
|
|
||||||
|
function sessionAgent(session: ChatSession) {
|
||||||
|
return agentRows.find((agent) => agent.id === session.agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sessions rail shown on the start side of the chat screen. */
|
||||||
|
function ChatView({ session }: { session: ChatSession }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const agent = sessionAgent(session);
|
||||||
|
const taskOverrideId = searchParams.get("task");
|
||||||
|
const task = taskOverrideId
|
||||||
|
? allTasks.find((candidate) => candidate.taskId === taskOverrideId)
|
||||||
|
: session.taskId
|
||||||
|
? allTasks.find((candidate) => candidate.taskId === session.taskId)
|
||||||
|
: undefined;
|
||||||
|
const project = session.projectId ? getProject(session.projectId) : undefined;
|
||||||
|
const [messages, setMessages] = useState<ChatMessageRecord[]>(() => {
|
||||||
|
const initial = initialMessages(agent ?? (agentRows[0] as AgentRow), task);
|
||||||
|
if (!session.firstMessage) return initial;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${session.id}-first`,
|
||||||
|
sender: "user",
|
||||||
|
body: session.firstMessage,
|
||||||
|
timestamp: "Just now",
|
||||||
|
},
|
||||||
|
...initial,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const toolsSidebar = useResizable({
|
||||||
|
defaultSize: 480,
|
||||||
|
minSizePx: 340,
|
||||||
|
maxSizePx: 820,
|
||||||
|
collapsible: true,
|
||||||
|
collapsedSize: 72,
|
||||||
|
snaps: [420, 560, 720],
|
||||||
|
autoSaveId: "chat-workspace-tools",
|
||||||
|
});
|
||||||
|
const [isToolsMaximized, setIsToolsMaximized] = useState(false);
|
||||||
|
const [isToolsTransitioning, setIsToolsTransitioning] = useState(false);
|
||||||
|
const toolsTransitionTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
if (!agent) return <ChatNotFound />;
|
||||||
|
|
||||||
|
const isActive = agent.status === "active";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (toolsTransitionTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toolsTransitionTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const animateToolsSize = (change: () => void) => {
|
||||||
|
change();
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
setIsToolsTransitioning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsToolsTransitioning(true);
|
||||||
|
if (toolsTransitionTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toolsTransitionTimerRef.current);
|
||||||
|
}
|
||||||
|
toolsTransitionTimerRef.current = window.setTimeout(
|
||||||
|
() => setIsToolsTransitioning(false),
|
||||||
|
TOOLS_PANEL_TRANSITION_MS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleToolsMaximize = () => {
|
||||||
|
if (isToolsMaximized) {
|
||||||
|
animateToolsSize(() => toolsSidebar.resize(480));
|
||||||
|
} else {
|
||||||
|
animateToolsSize(() => {
|
||||||
|
toolsSidebar.expand();
|
||||||
|
toolsSidebar.resize(720);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsToolsMaximized((current) => !current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = (body: string) => {
|
||||||
|
const trimmedBody = body.trim();
|
||||||
|
if (!trimmedBody) return;
|
||||||
|
|
||||||
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${session.id}-${Date.now()}`,
|
||||||
|
sender: "user",
|
||||||
|
body: trimmedBody,
|
||||||
|
timestamp: "Just now",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
header={
|
||||||
|
<LayoutHeader hasDivider padding={4}>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<Breadcrumbs variant="supporting">
|
||||||
|
<BreadcrumbItem href="/chat">Chat</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem isCurrent>{session.title}</BreadcrumbItem>
|
||||||
|
</Breadcrumbs>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<BackButton label="Back to sessions" href="/chat" iconOnly />
|
||||||
|
<Avatar src={agent.imageUrl} name={agent.name} alt={agent.name} size="lg" />
|
||||||
|
<StackItem size="fill">
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Heading level={1}>{agent.name}</Heading>
|
||||||
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
|
<StatusDot
|
||||||
|
variant={isActive ? "success" : "neutral"}
|
||||||
|
label={isActive ? "Active" : "Offline"}
|
||||||
|
isPulsing={isActive}
|
||||||
|
/>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{isActive ? "Active now" : "Offline"} · {backendLabel(agent.backend)}
|
||||||
|
{task ? ` · ${task.taskId}` : ""}
|
||||||
|
{project ? ` · ${project.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</StackItem>
|
||||||
|
<Button
|
||||||
|
label="Workspace tools"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon icon={IconLayoutSidebarRightExpand} size="sm" />}
|
||||||
|
onClick={() => {
|
||||||
|
animateToolsSize(() => toolsSidebar.expand());
|
||||||
|
setIsToolsMaximized(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="View agent"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate(`/agents/${agent.id}/overview`)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</LayoutHeader>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<LayoutContent role="main" padding={0} isScrollable={false}>
|
||||||
|
<ChatLayout
|
||||||
|
density="balanced"
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
composer={
|
||||||
|
<ChatComposer
|
||||||
|
placeholder={`Message ${agent.name}…`}
|
||||||
|
style={{ width: "100%", maxWidth: 960, marginInline: "auto" }}
|
||||||
|
onSubmit={sendMessage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageThread agent={agent} session={session} task={task} messages={messages} />
|
||||||
|
</ChatLayout>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
end={
|
||||||
|
<>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
isReversed
|
||||||
|
hasDivider
|
||||||
|
resizable={toolsSidebar.props}
|
||||||
|
label="Resize workspace tools"
|
||||||
|
/>
|
||||||
|
<LayoutPanel
|
||||||
|
resizable={toolsSidebar.props}
|
||||||
|
hasDivider={false}
|
||||||
|
padding={0}
|
||||||
|
isScrollable={false}
|
||||||
|
label="Workspace tools"
|
||||||
|
xstyle={isToolsTransitioning ? styles.toolsPanelTransition : undefined}
|
||||||
|
>
|
||||||
|
<WorkspaceToolsPanel
|
||||||
|
isMaximized={isToolsMaximized}
|
||||||
|
onToggleMaximize={toggleToolsMaximize}
|
||||||
|
onCollapse={() => {
|
||||||
|
animateToolsSize(() => toolsSidebar.collapse());
|
||||||
|
setIsToolsMaximized(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LayoutPanel>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const { sessionId = "" } = useParams();
|
||||||
|
const session = getChatSession(sessionId);
|
||||||
|
|
||||||
|
return session ? <ChatView key={session.id} session={session} /> : <ChatNotFound />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Fragment, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Badge } from "@astryxdesign/core/Badge";
|
||||||
|
import { Button } from "@astryxdesign/core/Button";
|
||||||
|
import { Icon } from "@astryxdesign/core/Icon";
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutHeader,
|
||||||
|
StackItem,
|
||||||
|
VStack,
|
||||||
|
} from "@astryxdesign/core/Layout";
|
||||||
|
import { Table, TableCell, TableRow } from "@astryxdesign/core/Table";
|
||||||
|
import { Heading, Text } from "@astryxdesign/core/Text";
|
||||||
|
import { TextInput } from "@astryxdesign/core/TextInput";
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
|
IconMessageChatbot,
|
||||||
|
IconPlus,
|
||||||
|
IconSearch,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { chatSessions, chatSessionStatusLabel } from "../lib/chat-sessions";
|
||||||
|
import { useFilteredList } from "../../../hooks/use-filtered-list";
|
||||||
|
import { columns } from "../components/session-table-columns";
|
||||||
|
import { groupSessions, sessionAgent, STATUS_GROUP_ORDER } from "../lib/chat-sessions-ui";
|
||||||
|
|
||||||
|
export function ChatSessionsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||||
|
() => new Set(STATUS_GROUP_ORDER),
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
query: search,
|
||||||
|
setQuery: setSearch,
|
||||||
|
filtered: filteredSessions,
|
||||||
|
} = useFilteredList(chatSessions, {
|
||||||
|
searchFields: (session) => [
|
||||||
|
session.title,
|
||||||
|
session.lastMessage,
|
||||||
|
session.taskId ?? "",
|
||||||
|
sessionAgent(session)?.name ?? "",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = useMemo(() => groupSessions(filteredSessions), [filteredSessions]);
|
||||||
|
const groupKeys = STATUS_GROUP_ORDER.filter((key) => (grouped.get(key)?.length ?? 0) > 0);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
header={
|
||||||
|
<LayoutHeader hasDivider padding={4}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<HStack gap={3} vAlign="center">
|
||||||
|
<StackItem size="fill">
|
||||||
|
<Heading level={1}>Chat</Heading>
|
||||||
|
</StackItem>
|
||||||
|
<Button
|
||||||
|
label="New chat"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
icon={<Icon icon={IconPlus} size="sm" />}
|
||||||
|
onClick={() => navigate("/chat/new")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<StackItem size="fill">
|
||||||
|
<TextInput
|
||||||
|
label="Search sessions"
|
||||||
|
isLabelHidden
|
||||||
|
placeholder="Search sessions…"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
startIcon={IconSearch}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{filteredSessions.length} session{filteredSessions.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</LayoutHeader>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<LayoutContent role="main" padding={0}>
|
||||||
|
{filteredSessions.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
textOverflow="truncate"
|
||||||
|
hasHover
|
||||||
|
>
|
||||||
|
{groupKeys.map((key) => {
|
||||||
|
const sessionsInGroup = grouped.get(key);
|
||||||
|
if (!sessionsInGroup || sessionsInGroup.length === 0) return null;
|
||||||
|
const isExpanded = expandedGroups.has(key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<TableRow
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => toggleGroup(key)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleGroup(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell colSpan={columns.length}>
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<Icon
|
||||||
|
icon={isExpanded ? IconChevronDown : IconChevronRight}
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
<Text type="body" weight="bold">
|
||||||
|
{chatSessionStatusLabel(key)}
|
||||||
|
</Text>
|
||||||
|
<Badge variant="neutral" label={String(sessionsInGroup.length)} />
|
||||||
|
</HStack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded
|
||||||
|
? sessionsInGroup.map((session) => (
|
||||||
|
<TableRow
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => navigate(`/chat/${session.id}`)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.key}>
|
||||||
|
{column.renderCell
|
||||||
|
? column.renderCell(session)
|
||||||
|
: String(session[column.key] ?? "")}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<VStack gap={2} hAlign="center" padding={8}>
|
||||||
|
<Icon icon={IconMessageChatbot} size="lg" color="secondary" />
|
||||||
|
<Heading level={2}>No sessions found</Heading>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
Try a different search, or start a new chat.
|
||||||
|
</Text>
|
||||||
|
<Button label="Clear search" variant="secondary" onClick={() => setSearch("")} />
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||