moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

21
apps/web/.env.example Normal file
View File

@@ -0,0 +1,21 @@
# apps/web environment variables
# Copy to .env.local and fill in the real values.
# NEVER commit .env.local — it is gitignored.
# PostHog cloud analytics — project "Default project" id 161685, org "Egzakta"
# Required for DAY0-04 (≥ 5 distinct onboarding_complete in 24h post-launch).
# The REAL phc_* key is pasted into apps/web/.env.local at Wave 1 / P2b
# and is BAKED INTO THE TAURI BUNDLE at P4-win compile time — if missing
# at build moment, the shipped binary has PostHog as a permanent no-op.
# Local SQLite telemetry (packages/core/src/telemetry.ts) continues alongside.
VITE_POSTHOG_KEY=phc_REPLACE_ME
# PR7b Auth — Clerk publishable key (shared instance with apps/www). Public-safe by
# design (ships in the client bundle); the SECRET key is server-side only and is NOT
# used by the desktop SPA (the local sidecar authorizes with its device token, not a
# Clerk JWT). Absent → /auth degrades to the accountless local-first state (honest).
# Like VITE_POSTHOG_KEY, this is baked into the Tauri bundle at compile time.
# Leave BLANK/commented here: the key is shape-validated (isPublishableKey), so a
# non-empty placeholder would be rejected anyway — but shipping it blank guarantees a
# verbatim copy degrades to the honest accountless state, never a crash.
# VITE_CLERK_PUBLISHABLE_KEY=

20
apps/web/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

26
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

31
apps/web/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Apply the persisted theme before hydration without weakening the
production script-src policy. The classic same-origin script blocks
parsing briefly so light-mode users do not see a dark first frame. -->
<script src="/theme-init.js"></script>
<style>
html { background-color: #14110b; }
html[data-theme="light"] { background-color: #f7f1e4; }
</style>
<link rel="icon" type="image/png" href="/waggle-logo.png" />
<link rel="apple-touch-icon" href="/waggle-logo.png" />
<title>Waggle OS</title>
<meta name="description" content="Workspace-native AI agent platform with persistent memory" />
<meta name="author" content="Marko Markovic" />
<meta property="og:title" content="Waggle OS" />
<meta property="og:description" content="Workspace-native AI agent platform with persistent memory" />
<meta property="og:type" content="website" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

102
apps/web/package.json Normal file
View File

@@ -0,0 +1,102 @@
{
"name": "@waggle/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"preview": "vite preview",
"test": "node --disable-warning=DEP0040 ../../node_modules/vitest/vitest.mjs run",
"test:watch": "vitest"
},
"dependencies": {
"@clerk/clerk-react": "^5.61.8",
"@clerk/themes": "^2.4.57",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.38.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.4.6",
"posthog-js": "^1.372.10",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"simple-icons": "^16.15.0",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/d3-force": "^3.0.10",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^6.4.3",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,3 @@
// Re-export the base fixture from the package
// Override or extend test/expect here if needed
export { test, expect } from "lovable-agent-playwright-config/fixture";

View File

@@ -0,0 +1,10 @@
import { createLovableConfig } from "lovable-agent-playwright-config/config";
export default createLovableConfig({
// Add your custom playwright configuration overrides here
// Example:
// timeout: 60000,
// use: {
// baseURL: 'http://localhost:3000',
// },
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,40 @@
<svg width="150" height="39" viewBox="0 0 150 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M145.867 11.5547C145.143 11.5547 144.503 11.3984 143.945 11.0859C143.388 10.7682 142.951 10.2865 142.633 9.64062C142.315 8.99479 142.156 8.19271 142.156 7.23438C142.156 6.375 142.315 5.6224 142.633 4.97656C142.956 4.32552 143.398 3.82552 143.961 3.47656C144.523 3.1224 145.159 2.94531 145.867 2.94531C146.565 2.94531 147.177 3.10677 147.703 3.42969C148.229 3.7526 148.635 4.22656 148.922 4.85156C149.214 5.47135 149.359 6.22135 149.359 7.10156C149.359 7.26302 149.359 7.42448 149.359 7.58594H142.891V6.42969H148.539L147.836 6.8125C147.836 6.21875 147.758 5.72396 147.602 5.32812C147.451 4.93229 147.227 4.63802 146.93 4.44531C146.638 4.2526 146.279 4.15625 145.852 4.15625C145.419 4.15625 145.036 4.27083 144.703 4.5C144.37 4.72917 144.107 5.06771 143.914 5.51562C143.727 5.96354 143.633 6.5026 143.633 7.13281V7.21875C143.633 7.91146 143.727 8.48958 143.914 8.95312C144.102 9.41667 144.37 9.76302 144.719 9.99219C145.068 10.2214 145.484 10.3359 145.969 10.3359C146.474 10.3359 146.893 10.1875 147.227 9.89062C147.565 9.58854 147.773 9.16146 147.852 8.60938H149.312C149.25 9.19271 149.073 9.70573 148.781 10.1484C148.49 10.5911 148.094 10.9375 147.594 11.1875C147.099 11.4323 146.523 11.5547 145.867 11.5547Z" fill="black"/>
<path d="M136.898 3.17188H138.32V5.13281L138.211 5.08594C138.294 4.66927 138.448 4.30469 138.672 3.99219C138.901 3.67969 139.195 3.4375 139.555 3.26562C139.914 3.09375 140.328 3.00781 140.797 3.00781C140.917 3.00781 141.039 3.01823 141.164 3.03906V4.47656C141.065 4.45573 140.969 4.4401 140.875 4.42969C140.781 4.41927 140.682 4.41406 140.578 4.41406C140.099 4.41406 139.693 4.51042 139.359 4.70312C139.026 4.89583 138.771 5.1849 138.594 5.57031C138.422 5.95052 138.336 6.42448 138.336 6.99219V11.3281H136.898V3.17188Z" fill="black"/>
<path d="M131.508 11.5547C130.784 11.5547 130.143 11.3984 129.586 11.0859C129.029 10.7682 128.591 10.2865 128.273 9.64062C127.956 8.99479 127.797 8.19271 127.797 7.23438C127.797 6.375 127.956 5.6224 128.273 4.97656C128.596 4.32552 129.039 3.82552 129.602 3.47656C130.164 3.1224 130.799 2.94531 131.508 2.94531C132.206 2.94531 132.818 3.10677 133.344 3.42969C133.87 3.7526 134.276 4.22656 134.562 4.85156C134.854 5.47135 135 6.22135 135 7.10156C135 7.26302 135 7.42448 135 7.58594H128.531V6.42969H134.18L133.477 6.8125C133.477 6.21875 133.398 5.72396 133.242 5.32812C133.091 4.93229 132.867 4.63802 132.57 4.44531C132.279 4.2526 131.919 4.15625 131.492 4.15625C131.06 4.15625 130.677 4.27083 130.344 4.5C130.01 4.72917 129.747 5.06771 129.555 5.51562C129.367 5.96354 129.273 6.5026 129.273 7.13281V7.21875C129.273 7.91146 129.367 8.48958 129.555 8.95312C129.742 9.41667 130.01 9.76302 130.359 9.99219C130.708 10.2214 131.125 10.3359 131.609 10.3359C132.115 10.3359 132.534 10.1875 132.867 9.89062C133.206 9.58854 133.414 9.16146 133.492 8.60938H134.953C134.891 9.19271 134.714 9.70573 134.422 10.1484C134.13 10.5911 133.734 10.9375 133.234 11.1875C132.74 11.4323 132.164 11.5547 131.508 11.5547Z" fill="black"/>
<path d="M119.477 0.125H120.914V5.66406L120.703 5.05469C120.802 4.6224 120.971 4.2474 121.211 3.92969C121.456 3.61198 121.766 3.36979 122.141 3.20312C122.516 3.03125 122.943 2.94531 123.422 2.94531C123.958 2.94531 124.419 3.05729 124.805 3.28125C125.195 3.50521 125.492 3.82292 125.695 4.23438C125.898 4.64062 126 5.11719 126 5.66406V11.3281H124.562V5.82031C124.562 5.28385 124.43 4.8724 124.164 4.58594C123.904 4.29948 123.518 4.15625 123.008 4.15625C122.596 4.15625 122.232 4.25781 121.914 4.46094C121.602 4.66406 121.357 4.96875 121.18 5.375C121.003 5.77604 120.914 6.26302 120.914 6.83594V11.3281H119.477V0.125Z" fill="black"/>
<path d="M110.523 11.5547C109.799 11.5547 109.159 11.3984 108.602 11.0859C108.044 10.7682 107.607 10.2865 107.289 9.64062C106.971 8.99479 106.812 8.19271 106.812 7.23438C106.812 6.375 106.971 5.6224 107.289 4.97656C107.612 4.32552 108.055 3.82552 108.617 3.47656C109.18 3.1224 109.815 2.94531 110.523 2.94531C111.221 2.94531 111.833 3.10677 112.359 3.42969C112.885 3.7526 113.292 4.22656 113.578 4.85156C113.87 5.47135 114.016 6.22135 114.016 7.10156C114.016 7.26302 114.016 7.42448 114.016 7.58594H107.547V6.42969H113.195L112.492 6.8125C112.492 6.21875 112.414 5.72396 112.258 5.32812C112.107 4.93229 111.883 4.63802 111.586 4.44531C111.294 4.2526 110.935 4.15625 110.508 4.15625C110.076 4.15625 109.693 4.27083 109.359 4.5C109.026 4.72917 108.763 5.06771 108.57 5.51562C108.383 5.96354 108.289 6.5026 108.289 7.13281V7.21875C108.289 7.91146 108.383 8.48958 108.57 8.95312C108.758 9.41667 109.026 9.76302 109.375 9.99219C109.724 10.2214 110.141 10.3359 110.625 10.3359C111.13 10.3359 111.549 10.1875 111.883 9.89062C112.221 9.58854 112.43 9.16146 112.508 8.60938H113.969C113.906 9.19271 113.729 9.70573 113.438 10.1484C113.146 10.5911 112.75 10.9375 112.25 11.1875C111.755 11.4323 111.18 11.5547 110.523 11.5547Z" fill="black"/>
<path d="M98.5234 3.17188H100.055L102.359 9.90625H102.086L104.344 3.17188H105.805L102.844 11.3281H101.516L98.5234 3.17188Z" fill="black"/>
<path d="M95.6328 3.17188H97.0703V11.3281H95.6328V3.17188ZM96.3516 1.92188C96.1797 1.92188 96.0182 1.88021 95.8672 1.79688C95.7214 1.70833 95.6042 1.59115 95.5156 1.44531C95.4323 1.29427 95.3906 1.13281 95.3906 0.960938C95.3906 0.789062 95.4323 0.630208 95.5156 0.484375C95.6042 0.333333 95.7214 0.216146 95.8672 0.132812C96.0182 0.0442708 96.1797 0 96.3516 0C96.5234 0 96.6823 0.0442708 96.8281 0.132812C96.9792 0.216146 97.0964 0.333333 97.1797 0.484375C97.2682 0.630208 97.3125 0.789062 97.3125 0.960938C97.3125 1.13281 97.2682 1.29427 97.1797 1.44531C97.0964 1.59115 96.9792 1.70833 96.8281 1.79688C96.6823 1.88021 96.5234 1.92188 96.3516 1.92188Z" fill="black"/>
<path d="M91.8672 0.125H93.3047V11.3281H91.8672V0.125Z" fill="black"/>
<path d="M84.5391 0.125H85.9766V11.3281H84.5391V0.125Z" fill="black"/>
<path d="M80.7734 0.125H82.2109V11.3281H80.7734V0.125Z" fill="black"/>
<path d="M77.0078 3.17188H78.4453V11.3281H77.0078V3.17188ZM77.7266 1.92188C77.5547 1.92188 77.3932 1.88021 77.2422 1.79688C77.0964 1.70833 76.9792 1.59115 76.8906 1.44531C76.8073 1.29427 76.7656 1.13281 76.7656 0.960938C76.7656 0.789062 76.8073 0.630208 76.8906 0.484375C76.9792 0.333333 77.0964 0.216146 77.2422 0.132812C77.3932 0.0442708 77.5547 0 77.7266 0C77.8984 0 78.0573 0.0442708 78.2031 0.132812C78.3542 0.216146 78.4714 0.333333 78.5547 0.484375C78.6432 0.630208 78.6875 0.789062 78.6875 0.960938C78.6875 1.13281 78.6432 1.29427 78.5547 1.44531C78.4714 1.59115 78.3542 1.70833 78.2031 1.79688C78.0573 1.88021 77.8984 1.92188 77.7266 1.92188Z" fill="black"/>
<path d="M64.0703 3.17188H65.5391L67.5078 10.3203H66.8984L69.0312 3.17188H70.6406L72.7266 10.3203H72.1875L74.1016 3.17188H75.4766L73.0859 11.3281H71.7344L69.5078 3.61719H70.1641L67.8281 11.3281H66.4688L64.0703 3.17188Z" fill="black"/>
<path d="M56.2891 11.5547C55.8516 11.5547 55.4505 11.4714 55.0859 11.3047C54.7266 11.138 54.4167 10.8854 54.1562 10.5469C53.901 10.2083 53.7109 9.78646 53.5859 9.28125L53.8984 9.60156V11.3281H52.5078V3.17188H53.9453V4.99219L53.6016 5.21875C53.7109 4.74479 53.8958 4.33854 54.1562 4C54.4167 3.65625 54.7344 3.39583 55.1094 3.21875C55.4896 3.03646 55.9036 2.94531 56.3516 2.94531C57.0443 2.94531 57.638 3.1224 58.1328 3.47656C58.6328 3.82552 59.013 4.32552 59.2734 4.97656C59.5339 5.6224 59.6641 6.38021 59.6641 7.25C59.6641 8.11458 59.5286 8.8724 59.2578 9.52344C58.987 10.1693 58.5964 10.6693 58.0859 11.0234C57.5755 11.3776 56.9766 11.5547 56.2891 11.5547ZM56.0781 10.3359C56.526 10.3359 56.9062 10.2083 57.2188 9.95312C57.5312 9.69271 57.7656 9.33333 57.9219 8.875C58.0781 8.41146 58.1562 7.875 58.1562 7.26562C58.1562 6.65625 58.0781 6.11979 57.9219 5.65625C57.7656 5.1875 57.5312 4.82292 57.2188 4.5625C56.9062 4.29688 56.526 4.16406 56.0781 4.16406C55.6302 4.16406 55.2448 4.29688 54.9219 4.5625C54.6042 4.82292 54.362 5.1875 54.1953 5.65625C54.0339 6.11979 53.9531 6.65625 53.9531 7.26562C53.9531 7.86979 54.0339 8.40365 54.1953 8.86719C54.362 9.33073 54.6042 9.69271 54.9219 9.95312C55.2448 10.2083 55.6302 10.3359 56.0781 10.3359ZM52.5078 9.66406H53.9453V14.2109H52.5078V9.66406Z" fill="black"/>
<path d="M47.2578 11.5547C46.8203 11.5547 46.4193 11.4714 46.0547 11.3047C45.6953 11.138 45.3854 10.8854 45.125 10.5469C44.8698 10.2083 44.6797 9.78646 44.5547 9.28125L44.8672 9.60156V11.3281H43.4766V3.17188H44.9141V4.99219L44.5703 5.21875C44.6797 4.74479 44.8646 4.33854 45.125 4C45.3854 3.65625 45.7031 3.39583 46.0781 3.21875C46.4583 3.03646 46.8724 2.94531 47.3203 2.94531C48.013 2.94531 48.6068 3.1224 49.1016 3.47656C49.6016 3.82552 49.9818 4.32552 50.2422 4.97656C50.5026 5.6224 50.6328 6.38021 50.6328 7.25C50.6328 8.11458 50.4974 8.8724 50.2266 9.52344C49.9557 10.1693 49.5651 10.6693 49.0547 11.0234C48.5443 11.3776 47.9453 11.5547 47.2578 11.5547ZM47.0469 10.3359C47.4948 10.3359 47.875 10.2083 48.1875 9.95312C48.5 9.69271 48.7344 9.33333 48.8906 8.875C49.0469 8.41146 49.125 7.875 49.125 7.26562C49.125 6.65625 49.0469 6.11979 48.8906 5.65625C48.7344 5.1875 48.5 4.82292 48.1875 4.5625C47.875 4.29688 47.4948 4.16406 47.0469 4.16406C46.599 4.16406 46.2135 4.29688 45.8906 4.5625C45.5729 4.82292 45.3307 5.1875 45.1641 5.65625C45.0026 6.11979 44.9219 6.65625 44.9219 7.26562C44.9219 7.86979 45.0026 8.40365 45.1641 8.86719C45.3307 9.33073 45.5729 9.69271 45.8906 9.95312C46.2135 10.2083 46.599 10.3359 47.0469 10.3359ZM43.4766 9.66406H44.9141V14.2109H43.4766V9.66406Z" fill="black"/>
<path d="M37.2344 11.5547C36.7292 11.5547 36.2734 11.4583 35.8672 11.2656C35.4609 11.0677 35.1406 10.7865 34.9062 10.4219C34.6771 10.0573 34.5625 9.63281 34.5625 9.14844C34.5625 8.40365 34.7865 7.82552 35.2344 7.41406C35.6875 6.9974 36.3203 6.72396 37.1328 6.59375L38.4297 6.38281C38.763 6.32552 39.0234 6.26302 39.2109 6.19531C39.3984 6.1224 39.5365 6.02865 39.625 5.91406C39.7135 5.79427 39.7578 5.63542 39.7578 5.4375C39.7578 5.21875 39.6979 5.01042 39.5781 4.8125C39.4583 4.61458 39.2734 4.45312 39.0234 4.32812C38.7734 4.20312 38.4635 4.14062 38.0938 4.14062C37.5729 4.14062 37.1484 4.27865 36.8203 4.55469C36.4974 4.82552 36.3203 5.20052 36.2891 5.67969H34.7812C34.7969 5.15885 34.9453 4.69271 35.2266 4.28125C35.5078 3.86458 35.8958 3.53906 36.3906 3.30469C36.8854 3.0651 37.4531 2.94531 38.0938 2.94531C38.7448 2.94531 39.3047 3.0625 39.7734 3.29688C40.2422 3.52604 40.599 3.86198 40.8438 4.30469C41.0938 4.7474 41.2188 5.27604 41.2188 5.89062V9.4375C41.2188 9.81771 41.2448 10.1693 41.2969 10.4922C41.3542 10.8099 41.4349 11.013 41.5391 11.1016V11.3281H40.0156C39.9531 11.1042 39.901 10.8438 39.8594 10.5469C39.8229 10.2448 39.8047 9.95052 39.8047 9.66406L40.0625 9.74219C39.9375 10.0807 39.7396 10.388 39.4688 10.6641C39.1979 10.9401 38.8698 11.1589 38.4844 11.3203C38.099 11.4766 37.6823 11.5547 37.2344 11.5547ZM37.5547 10.3516C38.0078 10.3516 38.4036 10.25 38.7422 10.0469C39.0807 9.83854 39.3385 9.5599 39.5156 9.21094C39.6979 8.85677 39.7891 8.46354 39.7891 8.03125V6.8125L39.9531 6.83594C39.8021 7.00781 39.6094 7.14323 39.375 7.24219C39.1458 7.34115 38.8359 7.42969 38.4453 7.50781L37.5859 7.67969C37.0599 7.78906 36.6745 7.95573 36.4297 8.17969C36.1849 8.39844 36.0625 8.70573 36.0625 9.10156C36.0625 9.48177 36.2031 9.78646 36.4844 10.0156C36.7708 10.2396 37.1276 10.3516 37.5547 10.3516Z" fill="black"/>
<path d="M26.1641 3.17188H27.5859V5.13281L27.4766 5.08594C27.5599 4.66927 27.7135 4.30469 27.9375 3.99219C28.1667 3.67969 28.4609 3.4375 28.8203 3.26562C29.1797 3.09375 29.5938 3.00781 30.0625 3.00781C30.1823 3.00781 30.3047 3.01823 30.4297 3.03906V4.47656C30.3307 4.45573 30.2344 4.4401 30.1406 4.42969C30.0469 4.41927 29.9479 4.41406 29.8438 4.41406C29.3646 4.41406 28.9583 4.51042 28.625 4.70312C28.2917 4.89583 28.0365 5.1849 27.8594 5.57031C27.6875 5.95052 27.6016 6.42448 27.6016 6.99219V11.3281H26.1641V3.17188Z" fill="black"/>
<path d="M19.9688 11.5547C19.4167 11.5547 18.9401 11.4479 18.5391 11.2344C18.1432 11.0208 17.8385 10.7109 17.625 10.3047C17.4167 9.89844 17.3125 9.40885 17.3125 8.83594V3.17188H18.75V8.67188C18.75 9.19792 18.888 9.60677 19.1641 9.89844C19.4401 10.1901 19.8411 10.3359 20.3672 10.3359C20.7682 10.3359 21.1198 10.237 21.4219 10.0391C21.7292 9.84115 21.9688 9.54948 22.1406 9.16406C22.3125 8.77344 22.3984 8.29948 22.3984 7.74219V3.17188H23.8359V11.3281H22.4297V8.83594L22.6719 9.4375C22.5521 9.86979 22.3672 10.2448 22.1172 10.5625C21.8724 10.8802 21.5677 11.125 21.2031 11.2969C20.8385 11.4688 20.4271 11.5547 19.9688 11.5547Z" fill="black"/>
<path d="M11.8672 11.5547C11.138 11.5547 10.4974 11.3776 9.94531 11.0234C9.39323 10.6693 8.96615 10.1667 8.66406 9.51562C8.36719 8.86458 8.21875 8.10677 8.21875 7.24219C8.21875 6.3776 8.36719 5.6224 8.66406 4.97656C8.96615 4.32552 9.39323 3.82552 9.94531 3.47656C10.4974 3.1224 11.138 2.94531 11.8672 2.94531C12.5964 2.94531 13.237 3.1224 13.7891 3.47656C14.3411 3.82552 14.7656 4.32552 15.0625 4.97656C15.3646 5.6224 15.5156 6.3776 15.5156 7.24219C15.5156 8.10677 15.3646 8.86458 15.0625 9.51562C14.7656 10.1667 14.3411 10.6693 13.7891 11.0234C13.237 11.3776 12.5964 11.5547 11.8672 11.5547ZM11.8672 10.3359C12.3151 10.3359 12.6979 10.2161 13.0156 9.97656C13.3385 9.73177 13.5833 9.3776 13.75 8.91406C13.9219 8.45052 14.0078 7.89323 14.0078 7.24219C14.0078 6.26302 13.8203 5.50521 13.4453 4.96875C13.0703 4.43229 12.5443 4.16406 11.8672 4.16406C11.4193 4.16406 11.0339 4.28385 10.7109 4.52344C10.3932 4.76302 10.1484 5.11458 9.97656 5.57812C9.8099 6.03646 9.72656 6.59115 9.72656 7.24219C9.72656 7.89323 9.8099 8.45052 9.97656 8.91406C10.1484 9.3776 10.3932 9.73177 10.7109 9.97656C11.0339 10.2161 11.4193 10.3359 11.8672 10.3359Z" fill="black"/>
<path d="M3.39062 6.17969L3.71094 7.53906L0 0.125H1.64844L4.41406 6H3.86719L6.70312 0.125H8.26562L4.53906 7.53906L4.85938 6.17969V11.3281H3.39062V6.17969Z" fill="black"/>
<path d="M140.19 38.4648C139.789 38.4648 139.448 38.4033 139.165 38.2803C138.882 38.1572 138.661 37.9567 138.502 37.6787C138.347 37.3962 138.27 37.0293 138.27 36.5781V32.2236H137.012V31.1914H137.135C137.445 31.1914 137.693 31.1436 137.88 31.0479C138.071 30.9521 138.215 30.804 138.311 30.6035C138.406 30.403 138.463 30.1364 138.481 29.8037L138.522 29.0996H139.527V31.3008L139.377 31.1914H141.1V32.2236H139.527V36.4961C139.527 36.8197 139.607 37.0498 139.767 37.1865C139.931 37.3232 140.159 37.3916 140.45 37.3916C140.637 37.3916 140.81 37.3734 140.97 37.3369V38.3691C140.828 38.4056 140.699 38.4307 140.58 38.4443C140.462 38.458 140.332 38.4648 140.19 38.4648Z" fill="black"/>
<path d="M134.476 31.1914H135.733V38.3281H134.476V31.1914ZM135.104 30.0977C134.954 30.0977 134.813 30.0612 134.681 29.9883C134.553 29.9108 134.451 29.8083 134.373 29.6807C134.3 29.5485 134.264 29.4072 134.264 29.2568C134.264 29.1064 134.3 28.9674 134.373 28.8398C134.451 28.7077 134.553 28.6051 134.681 28.5322C134.813 28.4548 134.954 28.416 135.104 28.416C135.255 28.416 135.394 28.4548 135.521 28.5322C135.654 28.6051 135.756 28.7077 135.829 28.8398C135.907 28.9674 135.945 29.1064 135.945 29.2568C135.945 29.4072 135.907 29.5485 135.829 29.6807C135.756 29.8083 135.654 29.9108 135.521 29.9883C135.394 30.0612 135.255 30.0977 135.104 30.0977Z" fill="black"/>
<path d="M128.057 28.5254H129.314V31.1914H128.057V28.5254ZM125.965 38.5264C125.354 38.5264 124.83 38.3737 124.393 38.0684C123.955 37.7585 123.622 37.3232 123.395 36.7627C123.167 36.1976 123.053 35.5345 123.053 34.7734C123.053 34.0169 123.171 33.3538 123.408 32.7842C123.65 32.2145 123.994 31.7747 124.44 31.4648C124.887 31.1504 125.409 30.9932 126.006 30.9932C126.389 30.9932 126.737 31.0661 127.052 31.2119C127.371 31.3577 127.642 31.5788 127.865 31.875C128.093 32.1712 128.262 32.5404 128.371 32.9824L128.057 32.7021V31.1914H129.314V38.3281H128.098V36.7354L128.357 36.5371C128.212 37.1615 127.924 37.6491 127.496 38C127.068 38.3509 126.557 38.5264 125.965 38.5264ZM126.19 37.46C126.582 37.46 126.917 37.346 127.195 37.1182C127.478 36.8857 127.69 36.5667 127.831 36.1611C127.977 35.751 128.05 35.2793 128.05 34.7461C128.05 34.2174 127.977 33.7503 127.831 33.3447C127.69 32.9391 127.478 32.6247 127.195 32.4014C126.917 32.1735 126.582 32.0596 126.19 32.0596C125.799 32.0596 125.466 32.1735 125.192 32.4014C124.919 32.6247 124.714 32.9391 124.577 33.3447C124.44 33.7458 124.372 34.2129 124.372 34.7461C124.372 35.2793 124.44 35.751 124.577 36.1611C124.714 36.5667 124.919 36.8857 125.192 37.1182C125.466 37.346 125.799 37.46 126.19 37.46Z" fill="black"/>
<path d="M120.161 28.5254H121.419V38.3281H120.161V28.5254Z" fill="black"/>
<path d="M116.866 31.1914H118.124V38.3281H116.866V31.1914ZM117.495 30.0977C117.345 30.0977 117.203 30.0612 117.071 29.9883C116.944 29.9108 116.841 29.8083 116.764 29.6807C116.691 29.5485 116.654 29.4072 116.654 29.2568C116.654 29.1064 116.691 28.9674 116.764 28.8398C116.841 28.7077 116.944 28.6051 117.071 28.5322C117.203 28.4548 117.345 28.416 117.495 28.416C117.646 28.416 117.785 28.4548 117.912 28.5322C118.044 28.6051 118.147 28.7077 118.22 28.8398C118.297 28.9674 118.336 29.1064 118.336 29.2568C118.336 29.4072 118.297 29.5485 118.22 29.6807C118.147 29.8083 118.044 29.9108 117.912 29.9883C117.785 30.0612 117.646 30.0977 117.495 30.0977Z" fill="black"/>
<path d="M111.445 38.5264C110.962 38.5264 110.545 38.4329 110.194 38.2461C109.848 38.0592 109.581 37.7881 109.395 37.4326C109.212 37.0771 109.121 36.6488 109.121 36.1475V31.1914H110.379V36.0039C110.379 36.4642 110.5 36.8219 110.741 37.0771C110.983 37.3324 111.334 37.46 111.794 37.46C112.145 37.46 112.452 37.3734 112.717 37.2002C112.986 37.027 113.195 36.7718 113.346 36.4346C113.496 36.0928 113.571 35.6781 113.571 35.1904V31.1914H114.829V38.3281H113.599V36.1475L113.811 36.6738C113.706 37.0521 113.544 37.3802 113.325 37.6582C113.111 37.9362 112.844 38.1504 112.525 38.3008C112.206 38.4512 111.846 38.5264 111.445 38.5264Z" fill="black"/>
<path d="M104.589 38.5264C104.206 38.5264 103.855 38.4535 103.536 38.3076C103.222 38.1618 102.951 37.9408 102.723 37.6445C102.499 37.3483 102.333 36.9792 102.224 36.5371L102.497 36.8174V38.3281H101.28V31.1914H102.538V32.7842L102.237 32.9824C102.333 32.5677 102.495 32.2122 102.723 31.916C102.951 31.6152 103.229 31.3874 103.557 31.2324C103.889 31.0729 104.252 30.9932 104.644 30.9932C105.25 30.9932 105.769 31.1481 106.202 31.458C106.64 31.7633 106.972 32.2008 107.2 32.7705C107.428 33.3356 107.542 33.9987 107.542 34.7598C107.542 35.5163 107.424 36.1794 107.187 36.749C106.95 37.3141 106.608 37.7516 106.161 38.0615C105.715 38.3714 105.19 38.5264 104.589 38.5264ZM104.404 37.46C104.796 37.46 105.129 37.3483 105.402 37.125C105.676 36.8971 105.881 36.5827 106.018 36.1816C106.154 35.776 106.223 35.3066 106.223 34.7734C106.223 34.2402 106.154 33.7708 106.018 33.3652C105.881 32.9551 105.676 32.6361 105.402 32.4082C105.129 32.1758 104.796 32.0596 104.404 32.0596C104.012 32.0596 103.675 32.1758 103.393 32.4082C103.115 32.6361 102.903 32.9551 102.757 33.3652C102.616 33.7708 102.545 34.2402 102.545 34.7734C102.545 35.3021 102.616 35.7692 102.757 36.1748C102.903 36.5804 103.115 36.8971 103.393 37.125C103.675 37.3483 104.012 37.46 104.404 37.46ZM101.28 28.5254H102.538V31.1914H101.28V28.5254Z" fill="black"/>
<path d="M93.3369 38.5264C92.6989 38.5264 92.1383 38.3714 91.6553 38.0615C91.1722 37.7516 90.7985 37.3118 90.5342 36.7422C90.2744 36.1725 90.1445 35.5094 90.1445 34.7529C90.1445 33.9964 90.2744 33.3356 90.5342 32.7705C90.7985 32.2008 91.1722 31.7633 91.6553 31.458C92.1383 31.1481 92.6989 30.9932 93.3369 30.9932C93.9749 30.9932 94.5355 31.1481 95.0186 31.458C95.5016 31.7633 95.873 32.2008 96.1328 32.7705C96.3971 33.3356 96.5293 33.9964 96.5293 34.7529C96.5293 35.5094 96.3971 36.1725 96.1328 36.7422C95.873 37.3118 95.5016 37.7516 95.0186 38.0615C94.5355 38.3714 93.9749 38.5264 93.3369 38.5264ZM93.3369 37.46C93.7288 37.46 94.0638 37.3551 94.3418 37.1455C94.6243 36.9313 94.8385 36.6214 94.9844 36.2158C95.1348 35.8102 95.21 35.3226 95.21 34.7529C95.21 33.8962 95.0459 33.2331 94.7178 32.7637C94.3896 32.2943 93.9294 32.0596 93.3369 32.0596C92.945 32.0596 92.6077 32.1644 92.3252 32.374C92.0472 32.5837 91.833 32.8913 91.6826 33.2969C91.5368 33.6979 91.4639 34.1833 91.4639 34.7529C91.4639 35.3226 91.5368 35.8102 91.6826 36.2158C91.833 36.6214 92.0472 36.9313 92.3252 37.1455C92.6077 37.3551 92.945 37.46 93.3369 37.46Z" fill="black"/>
<path d="M88.333 38.4648C87.932 38.4648 87.5902 38.4033 87.3076 38.2803C87.0251 38.1572 86.804 37.9567 86.6445 37.6787C86.4896 37.3962 86.4121 37.0293 86.4121 36.5781V32.2236H85.1543V31.1914H85.2773C85.5872 31.1914 85.8356 31.1436 86.0225 31.0479C86.2139 30.9521 86.3574 30.804 86.4531 30.6035C86.5488 30.403 86.6058 30.1364 86.624 29.8037L86.665 29.0996H87.6699V31.3008L87.5195 31.1914H89.2422V32.2236H87.6699V36.4961C87.6699 36.8197 87.7497 37.0498 87.9092 37.1865C88.0732 37.3232 88.3011 37.3916 88.5928 37.3916C88.7796 37.3916 88.9528 37.3734 89.1123 37.3369V38.3691C88.971 38.4056 88.8411 38.4307 88.7227 38.4443C88.6042 38.458 88.4743 38.4648 88.333 38.4648Z" fill="black"/>
<path d="M78.0791 38.5264C77.4456 38.5264 76.8851 38.3896 76.3975 38.1162C75.9098 37.8382 75.527 37.4167 75.249 36.8516C74.971 36.2865 74.832 35.5846 74.832 34.7461C74.832 33.9941 74.971 33.3356 75.249 32.7705C75.5316 32.2008 75.9189 31.7633 76.4111 31.458C76.9033 31.1481 77.4593 30.9932 78.0791 30.9932C78.6898 30.9932 79.2253 31.1344 79.6855 31.417C80.1458 31.6995 80.5013 32.1143 80.752 32.6611C81.0072 33.2035 81.1348 33.8597 81.1348 34.6299C81.1348 34.7712 81.1348 34.9124 81.1348 35.0537H75.4746V34.042H80.417L79.8018 34.377C79.8018 33.8574 79.7334 33.4245 79.5967 33.0781C79.4645 32.7318 79.2686 32.4743 79.0088 32.3057C78.7536 32.137 78.4391 32.0527 78.0654 32.0527C77.6872 32.0527 77.3522 32.153 77.0605 32.3535C76.7689 32.554 76.5387 32.8503 76.3701 33.2422C76.2061 33.6341 76.124 34.1058 76.124 34.6572V34.7324C76.124 35.3385 76.2061 35.8444 76.3701 36.25C76.5342 36.6556 76.7689 36.9587 77.0742 37.1592C77.3796 37.3597 77.7441 37.46 78.168 37.46C78.61 37.46 78.9769 37.3301 79.2686 37.0703C79.5648 36.806 79.7471 36.4323 79.8154 35.9492H81.0938C81.0391 36.4596 80.8841 36.9085 80.6289 37.2959C80.3737 37.6833 80.0273 37.9863 79.5898 38.2051C79.1569 38.4193 78.6533 38.5264 78.0791 38.5264Z" fill="black"/>
<path d="M71.9404 28.5254H73.1982V38.3281H71.9404V28.5254Z" fill="black"/>
<path d="M67.3467 38.5264C66.9639 38.5264 66.613 38.4535 66.2939 38.3076C65.9795 38.1618 65.7083 37.9408 65.4805 37.6445C65.2572 37.3483 65.0908 36.9792 64.9814 36.5371L65.2549 36.8174V38.3281H64.0381V31.1914H65.2959V32.7842L64.9951 32.9824C65.0908 32.5677 65.2526 32.2122 65.4805 31.916C65.7083 31.6152 65.9863 31.3874 66.3145 31.2324C66.6471 31.0729 67.0094 30.9932 67.4014 30.9932C68.0075 30.9932 68.527 31.1481 68.96 31.458C69.3975 31.7633 69.7301 32.2008 69.958 32.7705C70.1859 33.3356 70.2998 33.9987 70.2998 34.7598C70.2998 35.5163 70.1813 36.1794 69.9443 36.749C69.7074 37.3141 69.3656 37.7516 68.9189 38.0615C68.4723 38.3714 67.9482 38.5264 67.3467 38.5264ZM67.1621 37.46C67.554 37.46 67.8867 37.3483 68.1602 37.125C68.4336 36.8971 68.6387 36.5827 68.7754 36.1816C68.9121 35.776 68.9805 35.3066 68.9805 34.7734C68.9805 34.2402 68.9121 33.7708 68.7754 33.3652C68.6387 32.9551 68.4336 32.6361 68.1602 32.4082C67.8867 32.1758 67.554 32.0596 67.1621 32.0596C66.7702 32.0596 66.4329 32.1758 66.1504 32.4082C65.8724 32.6361 65.6605 32.9551 65.5146 33.3652C65.3734 33.7708 65.3027 34.2402 65.3027 34.7734C65.3027 35.3021 65.3734 35.7692 65.5146 36.1748C65.6605 36.5804 65.8724 36.8971 66.1504 37.125C66.4329 37.3483 66.7702 37.46 67.1621 37.46ZM64.0381 28.5254H65.2959V31.1914H64.0381V28.5254Z" fill="black"/>
<path d="M58.5762 38.5264C58.1341 38.5264 57.7354 38.4421 57.3799 38.2734C57.0244 38.1003 56.7441 37.8542 56.5391 37.5352C56.3385 37.2161 56.2383 36.8447 56.2383 36.4209C56.2383 35.7692 56.4342 35.2633 56.8262 34.9033C57.2227 34.5387 57.7764 34.2995 58.4873 34.1855L59.6221 34.001C59.9137 33.9508 60.1416 33.8962 60.3057 33.8369C60.4697 33.7731 60.5905 33.6911 60.668 33.5908C60.7454 33.486 60.7842 33.347 60.7842 33.1738C60.7842 32.9824 60.7318 32.8001 60.627 32.627C60.5221 32.4538 60.3604 32.3125 60.1416 32.2031C59.9229 32.0938 59.6517 32.0391 59.3281 32.0391C58.8724 32.0391 58.501 32.1598 58.2139 32.4014C57.9313 32.6383 57.7764 32.9665 57.749 33.3857H56.4297C56.4434 32.93 56.5732 32.5221 56.8193 32.1621C57.0654 31.7975 57.4049 31.5127 57.8379 31.3076C58.2708 31.098 58.7676 30.9932 59.3281 30.9932C59.8978 30.9932 60.3877 31.0957 60.7979 31.3008C61.208 31.5013 61.5202 31.7952 61.7344 32.1826C61.9531 32.57 62.0625 33.0326 62.0625 33.5703V36.6738C62.0625 37.0065 62.0853 37.3141 62.1309 37.5967C62.181 37.8747 62.2516 38.0524 62.3428 38.1299V38.3281H61.0098C60.9551 38.1322 60.9095 37.9043 60.873 37.6445C60.8411 37.3802 60.8252 37.1227 60.8252 36.8721L61.0508 36.9404C60.9414 37.2367 60.7682 37.5055 60.5312 37.7471C60.2943 37.9886 60.0072 38.18 59.6699 38.3213C59.3327 38.458 58.9681 38.5264 58.5762 38.5264ZM58.8564 37.4736C59.2529 37.4736 59.5993 37.3848 59.8955 37.207C60.1917 37.0247 60.4173 36.7809 60.5723 36.4756C60.7318 36.1657 60.8115 35.8216 60.8115 35.4434V34.377L60.9551 34.3975C60.8229 34.5479 60.6543 34.6663 60.4492 34.7529C60.2487 34.8395 59.9775 34.917 59.6357 34.9854L58.8838 35.1357C58.4235 35.2314 58.0863 35.3773 57.8721 35.5732C57.6579 35.7646 57.5508 36.0335 57.5508 36.3799C57.5508 36.7126 57.6738 36.9792 57.9199 37.1797C58.1706 37.3757 58.4827 37.4736 58.8564 37.4736Z" fill="black"/>
<path d="M49.1768 31.1914H50.5166L52.5332 37.084H52.2939L54.2695 31.1914H55.5479L52.957 38.3281H51.7949L49.1768 31.1914Z" fill="black"/>
<path d="M45.2666 38.5264C44.6286 38.5264 44.068 38.3714 43.585 38.0615C43.1019 37.7516 42.7282 37.3118 42.4639 36.7422C42.2041 36.1725 42.0742 35.5094 42.0742 34.7529C42.0742 33.9964 42.2041 33.3356 42.4639 32.7705C42.7282 32.2008 43.1019 31.7633 43.585 31.458C44.068 31.1481 44.6286 30.9932 45.2666 30.9932C45.9046 30.9932 46.4652 31.1481 46.9482 31.458C47.4313 31.7633 47.8027 32.2008 48.0625 32.7705C48.3268 33.3356 48.459 33.9964 48.459 34.7529C48.459 35.5094 48.3268 36.1725 48.0625 36.7422C47.8027 37.3118 47.4313 37.7516 46.9482 38.0615C46.4652 38.3714 45.9046 38.5264 45.2666 38.5264ZM45.2666 37.46C45.6585 37.46 45.9935 37.3551 46.2715 37.1455C46.554 36.9313 46.7682 36.6214 46.9141 36.2158C47.0645 35.8102 47.1396 35.3226 47.1396 34.7529C47.1396 33.8962 46.9756 33.2331 46.6475 32.7637C46.3193 32.2943 45.859 32.0596 45.2666 32.0596C44.8747 32.0596 44.5374 32.1644 44.2549 32.374C43.9769 32.5837 43.7627 32.8913 43.6123 33.2969C43.4665 33.6979 43.3936 34.1833 43.3936 34.7529C43.3936 35.3226 43.4665 35.8102 43.6123 36.2158C43.7627 36.6214 43.9769 36.9313 44.2549 37.1455C44.5374 37.3551 44.8747 37.46 45.2666 37.46Z" fill="black"/>
<path d="M35.8672 28.5254H37.1523V37.5557L36.9336 37.166H41.4248V38.3281H35.8672V28.5254Z" fill="black"/>
<path d="M25.6064 28.5254H26.8643V34.6299H26.5293L29.6465 31.1914H31.2461L26.3311 36.4961L26.8643 35.4023V38.3281H25.6064V28.5254ZM27.7051 34.4932L28.3887 33.3037L31.5264 38.3281H30.0156L27.7051 34.4932Z" fill="black"/>
<path d="M21.2109 38.5264C20.5911 38.5264 20.0511 38.4261 19.5908 38.2256C19.1351 38.0205 18.7819 37.7311 18.5312 37.3574C18.2806 36.9837 18.1507 36.5417 18.1416 36.0312H19.4609C19.4792 36.487 19.6501 36.8402 19.9736 37.0908C20.2972 37.3369 20.7188 37.46 21.2383 37.46C21.6758 37.46 22.0267 37.3643 22.291 37.1729C22.5599 36.9814 22.6943 36.7217 22.6943 36.3936C22.6943 36.1292 22.5986 35.9219 22.4072 35.7715C22.2204 35.6211 21.915 35.5026 21.4912 35.416L20.3154 35.1768C19.6273 35.04 19.1214 34.8145 18.7979 34.5C18.4743 34.181 18.3125 33.7686 18.3125 33.2627C18.3125 32.8343 18.4242 32.4492 18.6475 32.1074C18.8708 31.7611 19.1921 31.4899 19.6113 31.2939C20.0306 31.0934 20.5251 30.9932 21.0947 30.9932C21.6781 30.9932 22.1771 31.0957 22.5918 31.3008C23.0065 31.5059 23.3232 31.7907 23.542 32.1553C23.7607 32.5153 23.8792 32.9255 23.8975 33.3857H22.6055C22.5872 32.9665 22.4391 32.6383 22.1611 32.4014C21.8877 32.1598 21.5231 32.0391 21.0674 32.0391C20.7803 32.0391 20.5296 32.0846 20.3154 32.1758C20.1012 32.2624 19.9349 32.3877 19.8164 32.5518C19.6979 32.7113 19.6387 32.8958 19.6387 33.1055C19.6387 33.3424 19.7207 33.5316 19.8848 33.6729C20.0488 33.8141 20.3086 33.9189 20.6641 33.9873L21.9766 34.2402C22.4141 34.3268 22.7832 34.4567 23.084 34.6299C23.3848 34.7985 23.6126 35.015 23.7676 35.2793C23.9271 35.5436 24.0068 35.8512 24.0068 36.2021C24.0068 36.6943 23.8838 37.1159 23.6377 37.4668C23.3962 37.8177 23.0635 38.082 22.6396 38.2598C22.2158 38.4375 21.7396 38.5264 21.2109 38.5264Z" fill="black"/>
<path d="M12.3926 28.5254H13.917L17.417 38.3281H16.0293L13.0215 29.5508H13.2607L10.2188 38.3281H8.8584L12.3926 28.5254ZM10.8203 34.1992H15.5986V35.3477H10.8203V34.1992Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View File

@@ -0,0 +1,11 @@
(function () {
try {
var value = localStorage.getItem('waggle-theme');
var useLightTheme = value === 'light' ||
(value === 'system' && window.matchMedia &&
!window.matchMedia('(prefers-color-scheme: dark)').matches);
if (useLightTheme) document.documentElement.setAttribute('data-theme', 'light');
} catch (_error) {
// Storage can be unavailable in hardened webviews; dark is the default.
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

185
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,185 @@
import { lazy, Suspense, useEffect, type ReactNode } from "react";
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ServiceProvider } from "@/providers/ServiceProvider";
import { InstallProvider } from "@/providers/InstallProvider";
import { ThemeProvider } from "@/providers/ThemeProvider";
import AppErrorBoundary from "@/components/os/ErrorBoundary";
import WaggleClerkProvider from "@/providers/WaggleClerkProvider";
import AppShell, { IndexRedirect } from "@/components/os/AppShell";
import NotFound from "./pages/NotFound.tsx";
import { useToast } from "@/hooks/use-toast";
import { isTauri, listenDesktopNavigation, listenDesktopShellEvents } from "@/lib/tauri-bindings";
const HomeRoute = lazy(() => import("@/routes/HomeRoute"));
const WorkspaceRoute = lazy(() => import("@/routes/WorkspaceRoute"));
const MemoryRoute = lazy(() => import("@/routes/MemoryRoute"));
const ArtifactsRoute = lazy(() => import("@/routes/ArtifactsRoute"));
const FilesRoute = lazy(() => import("@/routes/FilesRoute"));
const AgentsRoute = lazy(() => import("@/routes/AgentsRoute"));
const AutomationsRoute = lazy(() => import("@/routes/AutomationsRoute"));
const SkillsRoute = lazy(() => import("@/routes/SkillsRoute"));
const ConnectorsRoute = lazy(() => import("@/routes/ConnectorsRoute"));
const McpsRoute = lazy(() => import("@/routes/McpsRoute"));
const MarketplaceRoute = lazy(() => import("@/routes/MarketplaceRoute"));
const LauncherRoute = lazy(() => import("@/routes/LauncherRoute"));
const RoomRoute = lazy(() => import("@/routes/RoomRoute"));
const WaggleDanceRoute = lazy(() => import("@/routes/WaggleDanceRoute"));
const ApprovalsRoute = lazy(() => import("@/routes/ApprovalsRoute"));
const TeamRoute = lazy(() => import("@/routes/TeamRoute"));
const SettingsRoute = lazy(() => import("@/routes/SettingsRoute"));
const VaultRoute = lazy(() => import("@/routes/VaultRoute"));
const ProfileRoute = lazy(() => import("@/routes/ProfileRoute"));
const MissionControlRoute = lazy(() => import("@/routes/MissionControlRoute"));
const TimelineRoute = lazy(() => import("@/routes/TimelineRoute"));
const EventsRoute = lazy(() => import("@/routes/EventsRoute"));
const UsageRoute = lazy(() => import("@/routes/UsageRoute"));
const BenchmarkRoute = lazy(() => import("@/routes/BenchmarkRoute"));
const PlatformRoute = lazy(() => import("@/routes/PlatformRoute"));
const WorkspacesRoute = lazy(() => import("@/routes/WorkspacesRoute"));
const PaymentSuccessRoute = lazy(() => import("@/routes/PaymentSuccessRoute"));
const AuthRoute = lazy(() => import("@/routes/AuthRoute"));
// Theme is now owned by <ThemeProvider>; the pre-paint apply lives in main.tsx
// (applyStoredThemeEarly) to avoid a flash of the wrong theme on load.
// Phase-0 motion-spec (the single source of motion truth). DEV-only and
// code-split so it never reaches the production bundle; the route below is
// registered only under import.meta.env.DEV.
const MotionSpec = import.meta.env.DEV ? lazy(() => import("./pages/MotionSpec")) : null;
const routeElement = (element: ReactNode) => (
<Suspense fallback={null}>{element}</Suspense>
);
const TauriDesktopEventBridge = () => {
const navigate = useNavigate();
const { toast } = useToast();
useEffect(() => {
if (!isTauri()) return undefined;
let active = true;
const unlisteners: Array<() => void> = [];
const registerUnlistener = (dispose: () => void) => {
if (active) {
unlisteners.push(dispose);
} else {
dispose();
}
};
void listenDesktopNavigation((path) => navigate(path))
.then(registerUnlistener)
.catch(() => undefined);
void listenDesktopShellEvents((notice) => toast(notice))
.then(registerUnlistener)
.catch(() => undefined);
return () => {
active = false;
for (const unlisten of unlisteners) {
unlisten();
}
};
}, [navigate, toast]);
return null;
};
/**
* Root application component — UX Refactor v2.1 P1a (conversion plan §1.1):
* `/` mounts the AppShell layout route (BootScreen gate + onboarding takeover
* + left nav + StatusBar + overlays + ChatHost); every screen is a child
* route rendered into the shell's single canvas via the §5.1 wrappers.
*/
const App = () => (
<ThemeProvider>
<ServiceProvider>
<InstallProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<TauriDesktopEventBridge />
{/* PR7b/D2(b): optional Clerk. With VITE_WAGGLE_ENABLE_CLERK=1 and a valid
VITE_CLERK_PUBLISHABLE_KEY, wraps the app in a themed, router-integrated
ClerkProvider; otherwise renders children untouched (fully accountless).
Inside BrowserRouter so it can wire Clerk's routerPush/replace to useNavigate. */}
<WaggleClerkProvider>
<AppErrorBoundary appName="Waggle OS" onClose={() => window.location.reload()}>
<Routes>
{/* ── PR7b: /auth is the ONE pre-shell route — sibling OUTSIDE the
AppShell subtree (no sidebar / StatusBar / boot gate). Inherits the
warm tokens (ThemeProvider) + the top-level AppErrorBoundary above. ── */}
<Route path="/auth" element={routeElement(<AuthRoute />)} />
{/* DEV-only motion vocabulary reference (Phase-0). Sibling OUTSIDE
the AppShell subtree — no boot gate / onboarding — so it renders
the demo directly. Stripped from production (see MotionSpec above). */}
{import.meta.env.DEV && MotionSpec && (
<Route
path="/motion-spec"
element={
<Suspense fallback={null}>
<MotionSpec />
</Suspense>
}
/>
)}
<Route path="/" element={<AppShell />}>
{/* §3.3/§2.2: index lands on the salvaged route once, /home after. */}
<Route index element={<IndexRedirect />} />
{/* ── Work ── */}
<Route path="home" element={routeElement(<HomeRoute />)} />
{/* PR6c (D15): /workspaces → the full All-workspaces shelf (was a §9.7 redirect to Home). */}
<Route path="workspaces" element={routeElement(<WorkspacesRoute />)} />
<Route path="workspaces/:workspaceId/:tab?" element={routeElement(<WorkspaceRoute />)} />
<Route path="memory/:mindScope?" element={routeElement(<MemoryRoute />)} />
<Route path="artifacts" element={routeElement(<ArtifactsRoute />)} />
<Route path="files" element={routeElement(<FilesRoute />)} />
{/* ── Intelligence ── */}
<Route path="agents" element={routeElement(<AgentsRoute />)} />
<Route path="automations" element={routeElement(<AutomationsRoute />)} />
<Route path="skills" element={routeElement(<SkillsRoute />)} />
<Route path="room" element={routeElement(<RoomRoute />)} />
<Route path="waggle-dance" element={routeElement(<WaggleDanceRoute />)} />
<Route path="approvals" element={routeElement(<ApprovalsRoute />)} />
{/* ── Extend ── */}
<Route path="connectors" element={routeElement(<ConnectorsRoute />)} />
<Route path="mcps" element={routeElement(<McpsRoute />)} />
<Route path="marketplace" element={routeElement(<MarketplaceRoute />)} />
<Route path="launcher" element={routeElement(<LauncherRoute />)} />
{/* ── Team (route registered; nav tier-hidden below TEAMS, D5) ── */}
<Route path="team" element={routeElement(<TeamRoute />)} />
{/* ── System (§9.4: System surfaces nest under /settings/*) ── */}
<Route path="settings" element={routeElement(<SettingsRoute />)} />
<Route path="settings/vault" element={routeElement(<VaultRoute />)} />
<Route path="settings/profile" element={routeElement(<ProfileRoute />)} />
<Route path="settings/mission-control" element={routeElement(<MissionControlRoute />)} />
<Route path="settings/timeline" element={routeElement(<TimelineRoute />)} />
<Route path="settings/events" element={routeElement(<EventsRoute />)} />
<Route path="settings/usage" element={routeElement(<UsageRoute />)} />
{/* ── PR6a: ⌘K-only static surfaces ── */}
<Route path="benchmarks" element={routeElement(<BenchmarkRoute />)} />
<Route path="platform" element={routeElement(<PlatformRoute />)} />
{/* ── PR7a: Stripe Checkout return URLs (checkout.ts:42-43) ── */}
<Route path="payment-success" element={routeElement(<PaymentSuccessRoute />)} />
<Route path="payment-cancelled" element={<Navigate to="/settings?tab=billing&checkout=cancelled" replace />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</AppErrorBoundary>
</WaggleClerkProvider>
</BrowserRouter>
</TooltipProvider>
</InstallProvider>
</ServiceProvider>
</ThemeProvider>
);
export default App;

View File

@@ -0,0 +1,45 @@
# Persona Bee Avatars
AI-generated bee mascots — **one unique avatar per Waggle persona (22/22)**,
wired in `apps/web/src/lib/personas.ts` (AVATAR_MAP, 1:1 by persona id).
**Wave Q (2026-07-06): the whole set was redrawn in the canonical
flat-geometric hex-bee language** — the same style as the landing personas grid
(`apps/www/public/brand/bee-*-dark.png`). The R9 5-judge panel flagged the
previous glossy cel-shaded sticker set as a second illustration dialect
("two mascot languages"); one dialect now covers landing + app.
## Style (canonical)
Flat geometric vector bee: hexagonal head, simple black dot eyes with white
glints, small smile, black-striped hexagon body, thick black outlines directly
on the shapes, flat golden honey palette (#e5a000 family, ~40° hue), NO
gradients, NO glow, NO background scene, transparent background. Each avatar
carries one distinct persona prop (quill+hex notebook, hex scales, megaphone,
interlocking hex gears, …). Reads clearly at 64px.
## Regeneration recipe (proven 2026-07-06)
1. Generate with `nano-banana` (key: `~/.nano-banana/.env`; pass `--api-key`
if a stale `GOOGLE_API_KEY` env shadows it), using THREE style references
from the landing set + transparency:
```bash
nano-banana "<BASE + persona prop>" \
-r bee-builder-dark.png -r bee-hunter-dark.png -r bee-orchestrator-dark.png \
-t -m pro -s 1K -a 1:1 -o <persona-id> -d <outdir>
```
Batch script with all 22 prompts: session scratchpad `gen-flat-avatars.sh`
(2026-07-06); BASE prompt is embedded there.
2. **Palette-correct** — generations consistently come out ~30° burnt-orange
instead of the refs' ~40° gold. Deterministic PIL pass (scratchpad
`fix-avatars.py`): halo rim → transparent, interior near-white → warm cream
#f7e8c8, orange family +10.5° hue / +0.02 sat. Verify: dominant hue of
opaque colored pixels should land 39-41°.
3. Drop the PNGs here named `<persona-id>.png` — imports in
`lib/personas.ts` are 1:1 by id.
Cost: ~$0.10/image (pro, 1K). Full 22-set ≈ $2.2.

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View File

@@ -0,0 +1,18 @@
/**
* UX Refactor v2.1 P1b (D3) — boot connect kickoff.
*
* MUST stay main.tsx's FIRST import: ES-module import hoisting evaluates this
* module before any sibling, so the adapter's connect attempt is in flight
* before any component module could possibly issue a request — that is what
* arms the adapter's ensureReady() deferral gate for the entire boot burst
* (ServiceProvider's effect runs LAST among mount effects because it is the
* outermost provider; without this kickoff every child mount fetch would fire
* token-less first).
*
* Errors are swallowed here: ServiceProvider owns retry/backoff and the
* user-facing connection state, and a settled-failed attempt releases (then
* re-arms) the gate rather than wedging it.
*/
import { adapter } from './lib/adapter';
adapter.connect().catch(() => { /* ServiceProvider surfaces connection state */ });

View File

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };

View File

@@ -0,0 +1,769 @@
/**
* UX Refactor v2.1 P1a — AppShell layout route (conversion plan §2.1 rule 1).
*
* Owns: BootScreen gate (FR #23 sequencing ported from the retired
* pages/Index.tsx), the §3.3 window-state migration boot (one-shot, before the
* first canvas render), onboarding takeover (§1.2 — wizard renders INSTEAD of
* nav+canvas), left nav (same getDockForTier/filterByBillingTier data the dock
* consumed, §1.3), StatusBar, global overlays (the old Desktop.tsx mount block
* relocated), the `waggle:open-app` shim (§2.3), the keep-alive ChatHost
* (§4.2), and `<Outlet/>` as the single canvas.
*
* Stage C (the flip): this IS the live shell — App.tsx mounts it as the `/`
* layout route; Desktop.tsx and the window manager are deleted (§3.1).
*/
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { AnimatePresence } from 'framer-motion';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Home, MessageSquare, Brain, ListTodo, Library, Network, Plug, Shield } from 'lucide-react';
import wallpaperDark from '@/assets/wallpaper.jpg';
import wallpaperLight from '@/assets/wallpaper-light.jpg';
import BootScreen from './BootScreen';
import StatusBar from './StatusBar';
import RouteTransition from './RouteTransition';
import Sidebar, { type SidebarNavItem } from './Sidebar';
import AppErrorBoundary from './ErrorBoundary';
import UpgradeModal from './overlays/UpgradeModal';
import { adapter } from '@/lib/adapter';
import { stashDeepLink } from '@/lib/app-deeplink';
import { writeLoginBriefingDismissed, writeLoginBriefingLastDismissedAt, readLoginBriefingDismissed, readSkipBriefingParam } from '@/lib/login-briefing';
import { prefetchBriefing, computeAwayDays, BRIEFING_ABSENCE_DAYS } from '@/lib/briefing-source';
import { homeCacheExists } from '@/lib/home-cache';
import { resolveReturningUserOnboarding, isOnboardingStatusKnownSync } from '@/hooks/useOnboarding';
import { shouldShowCoachMarks, readOnboardedThisSession, readForceTour, clearForceTour } from '@/lib/coach-marks-gate';
import { matchNavRoute, queryString, routeFor, routeForSearchResult } from '@/lib/routes';
import { bootWindowStateMigration, indexLandingRoute } from '@/lib/window-state-migration';
import { getDockForTier, BILLING_TIER_ORDER, type AppId, type DockEntry } from '@/lib/dock-tiers';
import { TIER_LABELS } from '@waggle/shared';
import { buildCommandCatalog, type CatalogCommand } from '@/lib/command-catalog';
import { ShellProvider, useShell } from '@/providers/ShellContext';
import { seedChat, useChatWidgetState } from '@/hooks/useChatWidgetState';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { useWaggleDance } from '@/hooks/useWaggleDance';
import { useBumpSessionCount } from '@/hooks/useDockLabels';
import { useDockNudge } from '@/hooks/useDockNudge';
import { useToast } from '@/hooks/use-toast';
const BOOT_KEY = 'waggle-booted';
const ChatHost = lazy(() => import('./ChatHost'));
const CommandCenter = lazy(() => import('./overlays/CommandCenter'));
const CreateWorkspaceDialog = lazy(() => import('./overlays/CreateWorkspaceDialog'));
const PersonaSwitcher = lazy(() => import('./overlays/PersonaSwitcher'));
const SpawnAgentDialog = lazy(() => import('./overlays/SpawnAgentDialog'));
const WorkspaceSwitcher = lazy(() => import('./overlays/WorkspaceSwitcher'));
const NotificationInbox = lazy(() => import('./overlays/NotificationInbox'));
const KeyboardShortcutsHelp = lazy(() => import('./overlays/KeyboardShortcutsHelp'));
const OnboardingWizard = lazy(() => import('./overlays/OnboardingWizard'));
const OnboardingTooltips = lazy(() => import('./overlays/OnboardingTooltips'));
const LoginBriefing = lazy(() => import('./overlays/LoginBriefing'));
const ContextRail = lazy(() => import('./overlays/ContextRail'));
const TrialExpiredModal = lazy(() => import('./overlays/TrialExpiredModal'));
const deferredShellElement = (element: ReactNode) => (
<Suspense fallback={null}>{element}</Suspense>
);
/**
* F32: workspace sub-tab → breadcrumb label. Mirrors WorkspaceRoute.WS_TABS +
* WorkspaceDesktopApp.TABS so the StatusBar crumb reflects the ACTIVE tab
* instead of collapsing every /workspaces/:id/* path to the dock's 'Chat'
* entry (the only dock route that prefix-matches them). A tab absent from this
* map falls back to 'Overview' (fail-safe, never a wrong crumb) — keep it in
* sync if a tab is added to those two lists.
*/
const WORKSPACE_TAB_LABELS: Record<string, string> = {
overview: 'Overview', chat: 'Chat', memory: 'Memory',
artifacts: 'Artifacts', files: 'Files', team: 'Team', tasks: 'Tasks',
};
/** Flatten zone-parents so nav active-state/title lookups see every app entry. */
function flattenAppEntries(entries: DockEntry[]): DockEntry[] {
const out: DockEntry[] = [];
for (const e of entries) {
if (e.type === 'app') out.push(e);
if (e.type === 'zone-parent' && e.children) {
out.push(...e.children.filter(c => c.type === 'app'));
}
}
return out;
}
/**
* Wave U Lane B (item 1) — briefing-landing state machine (pure, unit-tested).
*
* The "Catching you up" briefing is the session's OPENING greeting: it may fire
* only during the initial landing visit, and only when that landing surface is
* Home. This reducer derives that discipline from the live pathname stream so the
* gate never reads the raw pathname at render (which re-popped the modal on any
* in-session navigation to Home — s02: Settings→Home — the "double catch-up"):
* - 'pending' — pre-decision; the bare index '/' is transitional (IndexRedirect
* replaces it at once) so it never decides the landing.
* - 'armed' — the first real surface was Home and we have not since left it.
* - 'spent' — the landing surface was not Home, OR we have since left Home; a
* later return to Home can never re-arm it. Terminal.
* Held in React state ⇒ resets per app session (a fresh launch greets again),
* never persisted to localStorage.
*/
export type BriefingLanding = 'pending' | 'armed' | 'spent';
// Exported (not a component) so the discipline is unit-tested without mounting the
// shell — the lane owns no separate lib file to host it. Fast-refresh is a non-
// concern for this top-level route module.
// eslint-disable-next-line react-refresh/only-export-components
export function nextBriefingLanding(prev: BriefingLanding, pathname: string): BriefingLanding {
if (pathname === '/') return prev; // transitional index — no decision yet
if (prev === 'spent') return 'spent'; // opportunity already gone this session
return pathname.startsWith('/home') ? 'armed' : 'spent';
}
const ShellLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const {
workspaces, activeWorkspace, activeWorkspaceId,
selectWorkspace, createWorkspace, patchWorkspace, refreshWorkspaces, workspacesError,
workspacesLoading,
currentTier, billingTier, trialInfo, refreshTier, showTrialExpired, setShowTrialExpired,
notifications, unreadCount, markRead, markAllRead,
onboardingState, updateOnboarding, completeOnboarding,
offline, agentStatus,
overlays: ov,
contextRailTarget, setContextRailTarget,
} = useShell();
const { allSignals: waggleSignals } = useWaggleDance();
const overlaysRef = useRef(ov);
useEffect(() => {
overlaysRef.current = ov;
}, [ov]);
const waggleUnacknowledged = waggleSignals.filter(s => !s.acknowledged).length;
// W2A: no implicit workspaces[0] fallback — the chrome shows a workspace only
// when one was explicitly selected. Sidebar/StatusBar accept null names; the
// Chat spine item opens the WorkspaceSwitcher when there is no real selection.
const effectiveActiveWorkspaceId =
activeWorkspaceId && activeWorkspaceId !== 'local-default'
? activeWorkspaceId
: null;
const effectiveActiveWorkspace =
activeWorkspace ?? workspaces.find(ws => ws.id === effectiveActiveWorkspaceId) ?? null;
const firstAvailableWorkspaceId = useMemo(
() => workspaces.find(ws => ws.status !== 'archived')?.id ?? null,
[workspaces],
);
const chatShortcutWorkspaceId = effectiveActiveWorkspaceId ?? firstAvailableWorkspaceId;
const [pendingChatShortcut, setPendingChatShortcut] = useState(false);
const navigateToActiveChat = useCallback(() => {
if (chatShortcutWorkspaceId) {
selectWorkspace(chatShortcutWorkspaceId);
ov.setShowWorkspaceSwitcher(false);
navigate(routeFor('chat', { activeWorkspaceId: chatShortcutWorkspaceId }));
return;
}
if (workspacesLoading && !workspacesError) {
setPendingChatShortcut(true);
return;
}
// No workspace exists yet; ask the user to create or pick one.
ov.toggleWorkspaceSwitcher();
}, [chatShortcutWorkspaceId, navigate, ov, selectWorkspace, workspacesError, workspacesLoading]);
useEffect(() => {
if (!pendingChatShortcut) return;
if (chatShortcutWorkspaceId) {
setPendingChatShortcut(false);
selectWorkspace(chatShortcutWorkspaceId);
ov.setShowWorkspaceSwitcher(false);
navigate(routeFor('chat', { activeWorkspaceId: chatShortcutWorkspaceId }));
return;
}
if (!workspacesLoading) {
setPendingChatShortcut(false);
ov.toggleWorkspaceSwitcher();
}
}, [chatShortcutWorkspaceId, navigate, ov, pendingChatShortcut, selectWorkspace, workspacesLoading]);
// §4.2/§1.2: PersonaSwitcher (Ctrl+Shift+P) targets the ACTIVE workspace's
// chat widget (focused-window resolution died with focus tracking, §4.3);
// the patch-the-workspace-record fallback (Desktop.tsx:595-601) stays for
// the no-real-workspace case. No defaultAutonomy option here — P4
// inheritance is stamped only when ChatHost actually mounts the widget.
const hasRealActiveWorkspace = !!effectiveActiveWorkspaceId && effectiveActiveWorkspaceId !== 'local-default';
const { entry: activeChatEntry, setPersona: setActiveChatPersona } =
useChatWidgetState(effectiveActiveWorkspaceId ?? 'local-default');
// User display name for the sidebar user row (PR1 LOW #2). Best-effort via the
// existing identity surface; re-fetched on connect-settle because the first
// call can race the session-token attach and 401 → name:null. Falls back to
// "Account" in the row when unconfigured.
const [userName, setUserName] = useState<string | null>(null);
// F5: the UpgradeModal self-opens on a window event, so the shell can't see
// its open state without this. Feeds `anyModalOpen` so coach-marks hide under it.
const [upgradeOpen, setUpgradeOpen] = useState(false);
useEffect(() => {
let cancelled = false;
const loadIdentity = () => {
adapter.getIdentity()
.then(r => { if (!cancelled) setUserName(r.name ?? null); })
.catch(() => { /* identity is optional — the row degrades to "Account" */ });
};
loadIdentity();
window.addEventListener('waggle:connect-settled', loadIdentity);
return () => { cancelled = true; window.removeEventListener('waggle:connect-settled', loadIdentity); };
}, []);
// Theme reactivity — watch for data-theme mutations on <html>
// (relocated from Desktop.tsx:131-139).
const [theme, setTheme] = useState(() => document.documentElement.getAttribute('data-theme') ?? 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(document.documentElement.getAttribute('data-theme') ?? 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
// Session counter (waggle:session-count) — bumped once per page load. The
// retired Dock did this via its useDockLabels mount; the bump relocates here
// so the M-24/ENG-3 milestones below keep ticking. Called BEFORE useDockNudge
// so the bump effect runs first (Dock-child-before-Desktop-parent parity).
useBumpSessionCount();
// M-24 / ENG-3 zone nudges (relocated verbatim from Desktop.tsx:218-223 —
// the IA zones it points at survive as nav zones, §3.2).
const { toast } = useToast();
useDockNudge({
onNudge: (_milestone, copy) => {
toast({ title: copy.title, description: copy.description });
},
});
// §2.3: the ONE `waggle:open-app` listener (replaces Desktop.tsx:178-190).
// Stashes the intent for mount-time consumers (AutomationCenterApp), then
// navigates to the canonical URL. Live-listener consumers (UserProfileApp)
// only mount on the next render under the single canvas, so the same event
// is re-dispatched once after the target route has rendered — marked
// `redispatch: true` so this shim ignores its own re-dispatches.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as
| { appId?: AppId; tab?: string; automationId?: string; filter?: string; redispatch?: boolean }
| undefined;
if (!detail?.appId || detail.redispatch) return;
stashDeepLink({ appId: detail.appId, tab: detail.tab, automationId: detail.automationId, filter: detail.filter });
ov.setShowWorkspaceSwitcher(false);
navigate(
routeFor(detail.appId, { activeWorkspaceId: effectiveActiveWorkspaceId }) +
queryString({ tab: detail.tab, automationId: detail.automationId, filter: detail.filter }),
);
// Two rAFs ≈ the tick after the navigated-to route has committed.
requestAnimationFrame(() => requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('waggle:open-app', { detail: { ...detail, redispatch: true } }));
}));
};
window.addEventListener('waggle:open-app', handler);
return () => window.removeEventListener('waggle:open-app', handler);
}, [navigate, effectiveActiveWorkspaceId, ov]);
// Keyboard shortcuts — every app shortcut is a navigate() now (§2.2).
// Ctrl+W / Ctrl+Shift+M window handlers retire with the window manager
// (§3.1); Ctrl+Shift+N navigates to the active workspace's chat tab (§4.2).
useKeyboardShortcuts({
onOpenApp: (id) => {
ov.setShowWorkspaceSwitcher(false);
navigate(routeFor(id, { activeWorkspaceId: effectiveActiveWorkspaceId }));
},
onToggleGlobalSearch: ov.toggleGlobalSearch,
onTogglePersonaSwitcher: ov.togglePersonaSwitcher,
onToggleWorkspaceSwitcher: ov.toggleWorkspaceSwitcher,
onToggleKeyboardHelp: ov.toggleKeyboardHelp,
onNewChatWindow: navigateToActiveChat,
});
// §2.2 row 1: palette result clicks become pure URL navigation. The
// workspace-selection side effect is parity with Desktop.tsx:258-288.
const handleSearchNavigate = useCallback((type: string, id: string) => {
const route = routeForSearchResult(type, id, { activeWorkspaceId: effectiveActiveWorkspaceId });
if (!route) return;
if (type === 'workspace') {
const bareId = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
selectWorkspace(bareId);
} else if (type === 'session') {
const [, wsId] = id.split(':');
if (wsId) selectWorkspace(wsId);
}
ov.setShowWorkspaceSwitcher(false);
navigate(route);
}, [effectiveActiveWorkspaceId, selectWorkspace, navigate, ov]);
// Onboarding completion handlers (relocated from Desktop.tsx:290-313).
const handleOnboardingComplete = useCallback((_serverBaseUrl: string) => {
completeOnboarding();
// Atomic start. 409 (trial already started) is fine — refresh state
// either way so the StatusBar countdown picks up the existing timestamp.
adapter.startTrial().then(refreshTier).catch(refreshTier);
}, [completeOnboarding, refreshTier]);
const handleOnboardingFinish = useCallback((workspaceId: string, workspaceName: string, firstMessage?: string, personaId?: string) => {
selectWorkspace(workspaceId);
// §2.2/§4.2: seed the workspace's chat widget with the wizard-chosen
// persona + QW-1 starter prompt, then land on the chat tab — behavioral
// parity with Desktop's handleOnboardingFinish (Desktop.tsx:309-313,
// acceptance check 8). ChatHost consumes the seed on the widget's first
// mount. The name is resolved live from the workspaces list (refresh
// below), so the wizard's workspaceName arg is no longer needed.
void workspaceName;
// F2: auto-send the wizard's first task so "Let's go" lands the user in a
// live conversation instead of a pre-filled-but-unsent composer.
seedChat(workspaceId, { personaId, initialMessage: firstMessage, autoSend: true });
// P2 fix (acceptance check 8 live-run): the wizard usually finishes at
// pathname '/', and completing onboarding (normal-priority state) commits
// BEFORE this navigate (a v7_startTransition update) — so the shell
// mounts at '/', IndexRedirect fires, and its '/home' navigation queues
// after ours and wins. Hand the landing target to IndexRedirect so both
// navigation authorities agree; the navigate below remains the primary
// path when the wizard finishes at a non-index URL.
pendingWizardLanding = `/workspaces/${workspaceId}/chat`;
navigate(`/workspaces/${workspaceId}/chat`);
refreshWorkspaces();
}, [selectWorkspace, navigate, refreshWorkspaces]);
// Warm-Hive calm spine (ia.html): the always-visible nav is five fixed places;
// everything else lives one keystroke away in ⌘K. The StatusBar breadcrumb
// still derives from the full dock route table — every route is now reachable
// via ⌘K regardless of tier, so the label map must cover them all.
const labelEntries = useMemo(() => flattenAppEntries(getDockForTier('power', billingTier)), [billingTier]);
const activeRoute = useMemo(
() => matchNavRoute(location.pathname, labelEntries.map(e => e.route).filter((r): r is string => !!r)),
[location.pathname, labelEntries],
);
// F32: on a workspace sub-route the breadcrumb reflects the active tab (read
// straight from the pathname — the URL is the tab-state authority, see
// WorkspaceRoute). Every other route keeps the dock-route lookup. The regex
// requires an :id segment, so the bare /workspaces grid falls through to the
// dock entry (a pre-existing 'Chat' label, out of F32 scope).
const surfaceLabel = useMemo(() => {
const wsMatch = /^\/workspaces\/([^/]+)(?:\/([^/]+))?/.exec(location.pathname);
if (wsMatch) return WORKSPACE_TAB_LABELS[wsMatch[2] ?? 'overview'] ?? 'Overview';
return labelEntries.find(e => e.route === activeRoute)?.label ?? null;
}, [location.pathname, activeRoute, labelEntries]);
// F32: close any shell-level detail rail on a real route change, so a rail
// opened from a memory frame/entity, file, or chat message can't pin over the
// next page. Keyed on pathname (not search) so it survives same-surface
// sub-tab switches (e.g. /memory ?tab=timeline→graph).
useEffect(() => {
setContextRailTarget(null);
}, [location.pathname, setContextRailTarget]);
// Route changes should dismiss route-independent selection overlays. Without
// this, a workspace picker opened during a prior navigation can sit above the
// next surface and intercept sidebar clicks.
useEffect(() => {
const overlays = overlaysRef.current;
if (overlays.showWorkspaceSwitcher) overlays.setShowWorkspaceSwitcher(false);
}, [location.pathname, location.search]);
// Wave U Lane B (item 1): drive the briefing gate off the pathname STREAM via
// nextBriefingLanding, not the live pathname at render — so an in-session
// navigation to Home (Settings→Home) can never re-open the modal. The home hero
// already carries the catch-up when the landing surface wasn't Home.
const [briefingLanding, setBriefingLanding] = useState<BriefingLanding>('pending');
useEffect(() => {
setBriefingLanding(prev => nextBriefingLanding(prev, location.pathname));
}, [location.pathname]);
// Lane H item 4 — the "double catch-up collapse": the everyday catch-up is the
// home hero's recall strip, so the full modal is reserved for ≥7-day absences.
// Away time is derived from the SAME workspace lastActive stream the hero
// greeting reads (user activity, not machine cron writes); 0 when there is no
// activity yet, so a brand-new account never triggers it.
const briefingAwayDays = useMemo(() => computeAwayDays(workspaces), [workspaces]);
// Five-place spine + a power-tier "Pinned" group. Chat resolves to the active
// workspace's chat tab (routeFor falls back to /home with no workspace). The
// Agents & tasks badge surfaces unacknowledged coordination signals for now;
// PR3 refines it to the real pending-approvals/tasks count.
const isPro = currentTier === 'power' || currentTier === 'admin';
const billingRank = BILLING_TIER_ORDER[billingTier] ?? 0;
const spine: SidebarNavItem[] = useMemo(() => [
{ key: 'home', label: 'Home', icon: Home, to: '/home', match: ['/home'] },
// Chat resolves to the active workspace's chat tab; with no real workspace,
// routeFor falls back to /home (which Home already owns → the click feels
// dead, PR1 LOW #1). In that case open the workspace switcher instead so the
// user picks a workspace to chat in.
{
key: 'chat', label: 'Chat', icon: MessageSquare,
to: routeFor('chat', { activeWorkspaceId: effectiveActiveWorkspaceId }),
// F8: only the chat TAB (/workspaces/:id/chat) marks Chat active — a
// static '/workspaces' prefix wrongly lit Chat on Overview and every
// other workspace tab. Workspace-agnostic regex, no id coupling.
match: [], activeWhen: (p: string) => /^\/workspaces\/[^/]+\/chat(\/|$)/.test(p),
onClick: navigateToActiveChat,
},
{ key: 'memory', label: 'Memory', icon: Brain, to: '/memory', match: ['/memory'] },
{ key: 'agents', label: 'Agents', icon: ListTodo, to: '/agents', match: ['/agents', '/automations'], badge: waggleUnacknowledged || undefined },
{ key: 'library', label: 'Library', icon: Library, to: '/artifacts', match: ['/artifacts', '/files', '/skills'] },
], [effectiveActiveWorkspaceId, navigateToActiveChat, waggleUnacknowledged]);
const pinned: SidebarNavItem[] = useMemo(() => {
if (!isPro) return [];
const items: SidebarNavItem[] = [
{ key: 'swarm', label: 'Agent swarm', icon: Network, to: '/waggle-dance', match: ['/waggle-dance'] },
{ key: 'connectors', label: 'Connectors', icon: Plug, to: '/connectors', match: ['/connectors'] },
];
// Approvals is a TEAMS-tier surface (parity with dock-tiers minBillingTier).
if (billingRank >= BILLING_TIER_ORDER.TEAMS) items.push({ key: 'approvals', label: 'Approvals', icon: Shield, to: '/approvals', match: ['/approvals'] });
return items;
}, [isPro, billingRank]);
// Plan label for the user row (e.g. "Trial · 9d", "Solo", "Team").
const tierLabel = useMemo(() => {
if (billingTier === 'TRIAL' || (trialInfo.trialDaysRemaining > 0 && !trialInfo.trialExpired)) {
return trialInfo.trialDaysRemaining > 0 ? `Trial · ${trialInfo.trialDaysRemaining}d` : 'Trial';
}
return TIER_LABELS[billingTier];
}, [billingTier, trialInfo.trialDaysRemaining, trialInfo.trialExpired]);
// ⌘K curated catalog (Jump to / Do / Power tools + Pro "Pinned") → real routes.
const commandCatalog = useMemo(
() => buildCommandCatalog({ chatHref: routeFor('chat', { activeWorkspaceId: effectiveActiveWorkspaceId }), isPro, billingRank }),
[effectiveActiveWorkspaceId, isPro, billingRank],
);
const handleCatalogSelect = useCallback((cmd: CatalogCommand) => {
if (cmd.action === 'spawn') { ov.setShowSpawnAgent(true); return; }
if (cmd.to) {
ov.setShowWorkspaceSwitcher(false);
navigate(cmd.to);
}
}, [navigate, ov]);
// FR #33: when the onboarding wizard is active, render ONLY the wizard —
// no nav, no canvas, no overlays (§1.2 OnboardingWizard row: full-screen
// takeover at the layout level, any URL). Hooks above keep running so
// completion re-renders with workspaces/personas already populated.
if (!onboardingState.completed) {
return deferredShellElement(
<OnboardingWizard
serverBaseUrl={adapter.getServerUrl()}
state={onboardingState}
onUpdate={updateOnboarding}
onComplete={handleOnboardingComplete}
onDismiss={completeOnboarding}
onFinish={handleOnboardingFinish}
/>
);
}
// F5: any shell overlay open ⇒ suppress the coach-mark carousel (hide-but-keep
// tour state, per OnboardingTooltips' `suppressed` contract). Includes the
// event-driven UpgradeModal (via upgradeOpen) and the trial paywall.
const anyModalOpen =
ov.showGlobalSearch || ov.showCreateWorkspace || ov.showPersonaSwitcher ||
ov.showWorkspaceSwitcher || ov.showNotifications || ov.showKeyboardHelp ||
ov.showSpawnAgent || showTrialExpired || upgradeOpen;
return (
<div className="relative w-screen h-screen overflow-hidden select-none">
<img src={theme === 'light' ? wallpaperLight : wallpaperDark} alt="" className="absolute inset-0 w-full h-full object-cover" width={1920} height={1080} />
<div className="absolute inset-0 desktop-overlay" />
<StatusBar workspaceName={effectiveActiveWorkspace?.name}
focusedWindowLabel={surfaceLabel}
// W2C: workspace-first precedence — the chip means "the model this
// workspace's chat will use" (chat.ts: request ?? workspace.model ??
// config default), falling back to the global runtime model.
model={effectiveActiveWorkspace?.model ?? (agentStatus.model !== 'unknown' ? agentStatus.model : undefined)}
tokensUsed={agentStatus.tokensUsed} costUsd={agentStatus.costUsd} offline={offline}
unreadNotifications={unreadCount}
trialDaysRemaining={trialInfo.trialDaysRemaining} trialExpired={trialInfo.trialExpired}
onSearchClick={() => ov.setShowGlobalSearch(true)} onNotificationClick={ov.toggleNotifications} />
<div className="absolute inset-x-0 top-8 bottom-0 flex">
{/* Warm-Hive calm spine (ia.html) — five places + workspace pill +
⌘K tile + user row; all remaining depth lives in ⌘K. */}
<Sidebar
workspaceName={effectiveActiveWorkspace?.name ?? null}
spine={spine}
pinned={pinned}
onOpenWorkspaceSwitcher={ov.toggleWorkspaceSwitcher}
onOpenCommand={() => ov.setShowGlobalSearch(true)}
onSpawnAgent={() => ov.setShowSpawnAgent(true)}
userName={userName}
tierLabel={tierLabel}
/>
{/* Single canvas (§2.1 rule 1). Route wrappers bring their own
AppErrorBoundary, mirroring Desktop.tsx:556-558. */}
<main className="relative z-10 flex-1 min-w-0 overflow-hidden">
{/* §4.2 keep-alive: ChatHost portals one live ChatWindowInstance per
visited workspace, so navigation can't kill in-flight SSE
streams. It renders no layout DOM of its own. NOTE: it stays a
SIBLING of RouteTransition (never wrapped) so the crossfade can
never remount it and kill an in-flight stream. */}
{deferredShellElement(<ChatHost />)}
{/* Pillar 1.1 · Lane RT: the default fade-through crossfade + persistent
chrome for top-level route changes. Wraps ONLY the Outlet; the
sidebar + StatusBar above are outside this subtree, so they persist.
Feature-flagged + reduced-motion-aware; focus/AT ships inside it. */}
<RouteTransition />
</main>
</div>
{/* Overlays — Desktop.tsx:569-663 relocated; handlers retarget to
navigate() per §1.2 / §2.2. */}
{/* P7/D15 B3: the Win+K overlay sits outside the SurfaceBoundary-wrapped
Outlet, so an un-caught render throw here blanks the whole shell. Wrap
it in the same AppErrorBoundary the routes use; onClose dismisses it. */}
{ov.showGlobalSearch && deferredShellElement(
<AppErrorBoundary appName="Command Center" onClose={() => ov.setShowGlobalSearch(false)}>
<CommandCenter
open
onClose={() => ov.setShowGlobalSearch(false)}
onNavigate={handleSearchNavigate}
onExecute={() => { /* post-success hook — overlay closes itself; refresh feeds lazily */ }}
workspaceId={effectiveActiveWorkspaceId ?? undefined}
catalog={commandCatalog}
onCatalogSelect={handleCatalogSelect}
/>
</AppErrorBoundary>
)}
{ov.showCreateWorkspace && deferredShellElement(
<CreateWorkspaceDialog open onClose={() => ov.setShowCreateWorkspace(false)} onCreate={createWorkspace} />
)}
{/* §1.2/§4.2: PersonaSwitcher acts on the active workspace's chat widget
(widget state, NOT the workspace record — acceptance check 7); the
workspace-record patch survives as the no-real-workspace fallback. */}
{ov.showPersonaSwitcher && deferredShellElement(
<PersonaSwitcher open onClose={() => ov.setShowPersonaSwitcher(false)}
currentPersona={(hasRealActiveWorkspace ? activeChatEntry.personaId : undefined) ?? effectiveActiveWorkspace?.persona}
currentGroupId={effectiveActiveWorkspace?.agentGroupId}
currentTemplateId={effectiveActiveWorkspace?.templateId}
onSelect={(personaId) => {
if (hasRealActiveWorkspace) {
setActiveChatPersona(personaId);
} else if (effectiveActiveWorkspaceId) {
patchWorkspace(effectiveActiveWorkspaceId, { persona: personaId, agentGroupId: undefined });
}
}}
onSelectGroup={(groupId) => { if (effectiveActiveWorkspaceId) patchWorkspace(effectiveActiveWorkspaceId, { agentGroupId: groupId, persona: undefined }); }} />
)}
{ov.showWorkspaceSwitcher && deferredShellElement(
<WorkspaceSwitcher open onClose={() => ov.setShowWorkspaceSwitcher(false)}
workspaces={workspaces} activeWorkspaceId={effectiveActiveWorkspaceId}
error={workspacesError} onRetry={() => { void refreshWorkspaces(); }}
onCreateNew={() => ov.setShowCreateWorkspace(true)}
onViewAll={() => { ov.setShowWorkspaceSwitcher(false); navigate('/workspaces'); }}
onSelect={(id) => { selectWorkspace(id); ov.setShowWorkspaceSwitcher(false); navigate(`/workspaces/${id}`); }} />
)}
{ov.showNotifications && deferredShellElement(
<NotificationInbox open onClose={() => ov.setShowNotifications(false)} notifications={notifications} onMarkRead={markRead} onMarkAllRead={markAllRead} />
)}
{ov.showKeyboardHelp && deferredShellElement(
<KeyboardShortcutsHelp open onClose={() => ov.setShowKeyboardHelp(false)} />
)}
{ov.showSpawnAgent && deferredShellElement(
<SpawnAgentDialog open onClose={() => ov.setShowSpawnAgent(false)}
workspaces={workspaces} activeWorkspaceId={effectiveActiveWorkspaceId} onWorkspaceCreated={(ws) => selectWorkspace(ws.id)}
onSpawned={({ roomId, runId }) => {
ov.setShowSpawnAgent(false);
navigate(`/room?room=${encodeURIComponent(roomId)}&run=${encodeURIComponent(runId)}`);
}} />
)}
{shouldShowCoachMarks({
completed: onboardingState.completed,
tooltipsDismissed: !!onboardingState.tooltipsDismissed,
completedAt: onboardingState.completedAt ?? null,
completedThisSession: readOnboardedThisSession(),
forceTour: readForceTour(),
}) && deferredShellElement(
<OnboardingTooltips
templateId={onboardingState.templateId}
onDismiss={() => { clearForceTour(); updateOnboarding({ tooltipsDismissed: true }); }}
suppressed={anyModalOpen}
/>
)}
{/* FR #45: one post-onboarding overlay at a time — Tour first, then the
briefing once Tour is dismissed (gating relocated from Desktop.tsx:621-637).
Home-only: the greeting belongs to the cockpit — overlaying Memory or
Skills hides the very surfaces that prove the product's claims.
Wave Q Lane A (item 2 — one problem, one voice): when the sidecar is
unreachable the SAME root cause already surfaces as Home's own error
state + the NoModelBanner, so suppress the briefing entirely rather than
stack a third symptom on top. The connection problem is announced once.
Wave U Lane B (item 1 — interruption discipline): gate on briefingLanding
('armed' = Home was the session's landing surface AND we haven't left it),
NOT the live pathname alone — so a mid-session Settings→Home never re-pops
it. The trailing pathname check absorbs the one-frame effect lag.
Lane H item 4: additionally require a ≥7-day absence — otherwise the home
hero's recall strip is the catch-up, and this modal stays closed. */}
{onboardingState.completed && onboardingState.tooltipsDismissed && ov.showLoginBriefing
&& briefingLanding === 'armed' && location.pathname.startsWith('/home') && !offline
&& briefingAwayDays >= BRIEFING_ABSENCE_DAYS && (
deferredShellElement(
<LoginBriefing
onDismiss={(permanent) => {
if (permanent) writeLoginBriefingDismissed(true);
writeLoginBriefingLastDismissedAt();
ov.setShowLoginBriefing(false);
}}
onOpenWorkspace={(wsId) => { writeLoginBriefingLastDismissedAt(); selectWorkspace(wsId); navigate(routeFor('chat', { activeWorkspaceId: wsId })); ov.setShowLoginBriefing(false); }}
/>
)
)}
{/* Phase C.1: Context Rail (owned by the shell; surfaces feed it via
onContextRail props — §1.2 last row). */}
{contextRailTarget && deferredShellElement(
<ContextRail target={contextRailTarget} onClose={() => setContextRailTarget(null)} />
)}
<UpgradeModal
onOpenChange={setUpgradeOpen}
onStartTrial={() => {
adapter.startTrial().then(refreshTier).catch(refreshTier);
}}
onUpgrade={(tier) => {
// PR7a: navigate to hosted Stripe Checkout (the URL was previously
// discarded — a dead happy path). Same-tab assign rather than a deferred
// window.open: the open happens after an awaited round-trip, outside the
// user-gesture window, so a popup blocker / Tauri WebView could swallow it.
// Hosted Checkout redirects back to /payment-success on completion.
adapter.createCheckoutSession(tier)
.then(({ url }) => { if (url) window.location.assign(url); })
.catch(() => { navigate('/settings?tab=billing'); });
}}
/>
{showTrialExpired && deferredShellElement(
<TrialExpiredModal
open
onDismiss={() => setShowTrialExpired(false)}
onUpgrade={(tier) => {
setShowTrialExpired(false);
// PR7a: same-tab navigate to hosted Checkout (avoids the deferred-popup
// blocker; redirects back to /payment-success). Plan-tab fallback on failure.
adapter.createCheckoutSession(tier)
.then(({ url }) => { if (url) window.location.assign(url); })
.catch(() => { navigate('/settings?tab=billing'); });
}}
/>
)}
</div>
);
};
/**
* Boot gate (FR #23, ported from pages/Index.tsx:14-36): the boot signal is
* split into "boot finished" (gates BootScreen exit animation) and "show
* shell" (gates the layout + its overlays) so the exit transition finishes
* BEFORE the shell mounts. ShellProvider mounts inside the gate so its hooks
* start fetching post-boot, exactly when Desktop's hooks start today.
*/
const AppShell = () => {
// §3.3: one-shot `waggle-window-state-v1` migration, run on the first shell
// render — BEFORE the first canvas render (the Outlet only mounts inside
// ShellLayout below). The salvage side effects (chat-state merge + key
// removal) run on every entry path; the salvaged initialRoute is applied
// only via IndexRedirect when the app ENTERED on '/' (a typed deep link
// always wins — acceptance check 2).
useState(() => bootWindowStateMigration(window.location.pathname));
// Wave T Lane A (item 2): warm the LoginBriefing payload cache while the boot
// sequence runs (~4s), so the briefing greets with content instead of opening
// as a bare spinner. Skipped when the user turned the briefing off. Fire-and-
// forget; the adapter's connect gate defers the requests until the sidecar is
// reachable, and prefetchBriefing swallows its own rejection.
useEffect(() => {
if (!readLoginBriefingDismissed() && !readSkipBriefingParam()) prefetchBriefing();
}, []);
const [initialBooted] = useState(() => {
const params = new URLSearchParams(window.location.search);
const shouldSkipBoot = params.get('skipOnboarding') === 'true' || params.get('skipBoot') === 'true';
if (shouldSkipBoot) {
localStorage.setItem(BOOT_KEY, 'true');
return true;
}
return localStorage.getItem(BOOT_KEY) !== null;
});
// Wave T Lane A (item 1): the onboarding wizard flashes for ~1s for a
// server-onboarded user whose webview localStorage is fresh — the P4
// /api/onboarding/status probe only resolves AFTER the shell mounts, so the
// wizard paints before the auto-complete lands. Hold boot until the decision
// is KNOWN: sync when localStorage already settles it, else probe the server
// (capped so a dead endpoint can't brick boot — 3s, inside the boot screen's
// own 3-4s runtime, because a 1.5s cap still let the wizard flash on a cold
// dev server where the status roundtrip runs long). resolveReturningUser-
// Onboarding persists the completed flag so useOnboarding reads it
// synchronously and never renders the wizard for an onboarded user.
const [onboardingResolved, setOnboardingResolved] = useState(isOnboardingStatusKnownSync);
useEffect(() => {
if (onboardingResolved) return;
let settled = false;
const finish = () => { if (!settled) { settled = true; setOnboardingResolved(true); } };
void resolveReturningUserOnboarding().finally(finish);
const cap = window.setTimeout(finish, 3000);
return () => window.clearTimeout(cap);
}, [onboardingResolved]);
const [booted, setBooted] = useState(initialBooted);
const [showShell, setShowShell] = useState(() => initialBooted && isOnboardingStatusKnownSync());
// Lane H item 5: a warm session (returning user WITH a cache-first Home payload
// to paint behind the boot screen) shortens the brand flash to ≤500ms. A cold /
// day-0 launch (nothing cached to paint) keeps the full brand moment.
const [warmBoot] = useState(() => initialBooted && homeCacheExists());
// Fast path with no BootScreen to animate out (already booted this session):
// reveal the shell once onboarding resolves, since onExitComplete never fires.
useEffect(() => {
if (initialBooted && onboardingResolved) setShowShell(true);
}, [initialBooted, onboardingResolved]);
const handleBootComplete = () => {
localStorage.setItem(BOOT_KEY, 'true');
setBooted(true);
};
// Hold the BootScreen until BOTH the boot sequence finished AND onboarding
// status is known (item 1) — only then may it animate out and the shell mount.
const bootComplete = booted && onboardingResolved;
return (
<>
{/* Wave U Lane D: `ready` shortens the boot floor. The boot screen shows
its brand moment then exits the instant the shell's deps resolve —
onboarding resolution is the one genuinely-slow pre-boot dependency (a
server probe capped at 3s above). The briefing prefetch is fire-and-
forget on mount, and the workspace store warms inside ShellProvider
(post-boot), so neither can gate the floor. While onboarding is
unresolved the boot holds past the floor rather than flash the wizard. */}
<AnimatePresence onExitComplete={() => setShowShell(true)}>
{!bootComplete && <BootScreen onComplete={handleBootComplete} ready={onboardingResolved} warm={warmBoot} />}
</AnimatePresence>
{showShell && (
<ShellProvider>
<ShellLayout />
</ShellProvider>
)}
</>
);
};
/**
* Index-route element (`/`): redirects to the §3.3 salvaged route exactly
* once (the boot entry), '/home' on every later visit. Replaces the old
* Desktop.tsx:192-206 launch-flip (§2.2 — the index redirect subsumes it).
*/
/** One-shot landing target set by handleOnboardingFinish — see the P2 note
* there. Read (not cleared) in IndexRedirect's initializer so a StrictMode
* double-run stays consistent; cleared in its mount effect. */
let pendingWizardLanding: string | null = null;
export const IndexRedirect = () => {
const [to] = useState(() => pendingWizardLanding ?? indexLandingRoute());
useEffect(() => { pendingWizardLanding = null; }, []);
return <Navigate to={to} replace />;
};
export default AppShell;

View File

@@ -0,0 +1,237 @@
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { useState, useEffect, useCallback, useRef } from "react";
import waggleLogoDark from "@/assets/waggle-logo.jpeg";
import waggleLogoLight from "@/assets/waggle-logo.png";
import { useIsLightTheme } from "@/hooks/useIsLightTheme";
import { DUR, EASE_OUT } from "@/lib/motion/tokens";
const PHASES = [
"Initializing core systems…",
"Loading agent kernel…",
"Connecting to hive network…",
"Mounting workspaces…",
"Ready.",
];
const PHASE_DURATION = 400;
// Wave U Lane D (item 1): perceptual boot floor. The boot screen shows for at
// least this long — enough for the brand moment — then exits the instant the
// shell's data dependencies are ready (`ready` prop). Replaces the old fixed
// ~2.3s choreography floor that made returning users sit through dead air.
const MIN_BRAND_MS = 850;
// Lane H item 5: with cache-first paint there is real content waiting behind the
// boot screen for a WARM session, so the brand moment drops to a ≤500ms flash —
// no reason to dwell over content that's already there. COLD / day-0 keeps the
// full 850ms floor (nothing to paint, so the brand moment earns its beat).
const WARM_BRAND_MS = 500;
const SKIP_HINT_DELAY = 1000;
const BootScreen = ({ onComplete, ready = true, warm = false }: { onComplete: () => void; ready?: boolean; warm?: boolean }) => {
const floorMs = warm ? WARM_BRAND_MS : MIN_BRAND_MS;
const [phase, setPhase] = useState(0);
const [floorElapsed, setFloorElapsed] = useState(false);
const [showSkipHint, setShowSkipHint] = useState(false);
const completedRef = useRef(false);
const reduceMotion = useReducedMotion();
// Logo asset varies by theme: jpeg (solid dark backing, honey W) reads well
// on the hive-950 dark background; png (transparent, black "WAGGLE" text)
// reads well on the cream light background.
const isLight = useIsLightTheme();
const waggleLogo = isLight ? waggleLogoLight : waggleLogoDark;
// Fire onComplete at most once — the floor+ready path and the manual skip
// (click / any key) both race to exit; whichever wins, the other is a no-op.
const finish = useCallback(() => {
if (completedRef.current) return;
completedRef.current = true;
onComplete();
}, [onComplete]);
const handleSkip = useCallback(() => {
finish();
}, [finish]);
// Perceptual floor: hold the boot screen for at least floorMs (WARM_BRAND_MS
// for a cache-first warm session, MIN_BRAND_MS cold) so the brand moment lands,
// no matter how fast deps resolve.
useEffect(() => {
const t = setTimeout(() => setFloorElapsed(true), floorMs);
return () => clearTimeout(t);
}, [floorMs]);
// Exit once the floor has elapsed AND the shell's deps are ready. While deps
// are genuinely unresolved (ready=false) the boot holds past the floor — the
// phase choreography below settles on "Ready." and waits (item 1).
useEffect(() => {
if (floorElapsed && ready) finish();
}, [floorElapsed, ready, finish]);
// Visual phase choreography — advances on its own cadence, decoupled from the
// exit trigger so shortening the floor never truncates it mid-transition.
useEffect(() => {
if (phase < PHASES.length - 1) {
const t = setTimeout(() => setPhase(p => p + 1), PHASE_DURATION);
return () => clearTimeout(t);
}
}, [phase]);
useEffect(() => {
const handleKeyDown = () => handleSkip();
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSkip]);
useEffect(() => {
const t = setTimeout(() => setShowSkipHint(true), SKIP_HINT_DELAY);
return () => clearTimeout(t);
}, []);
const progress = ((phase + 1) / PHASES.length) * 100;
return (
<motion.div
initial={{ opacity: 1 }}
// Item 2: the exit stays choreographed (fade) at the shorter floor; under
// reduced motion it becomes an instant swap (no fade, no scale). The 0.5s
// easeInOut is a deliberate bespoke boot exit — a cinematic hand-off that
// sits OFF the standard DUR grid on purpose (no symmetric in-out easing
// token exists, and the 0.4 settle grade would clip the fade). Pinned by
// wave-u-boot-warm-start.test.tsx.
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 1.05 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.5, ease: "easeInOut" }}
className="fixed inset-0 z-[9999] bg-background flex flex-col items-center justify-center cursor-pointer"
data-testid="boot-screen"
role="status"
aria-live="polite"
aria-label={`Waggle booting — ${PHASES[phase]}. Click or press any key to skip.`}
onClick={handleSkip}
>
{/* Subtle radial glow */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-primary/5 blur-[120px]" />
</div>
{/* Logo */}
<motion.div
initial={{ opacity: 0, scale: 0.5, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
// Justified off-family spring (ζ≈0.707 vs the token band 0.740.77): the
// boot logo is the one bespoke cinematic entrance — slightly bouncier by
// design; not a reusable UI tier, so it stays a literal (Phase-0 rule).
transition={{ type: "spring", stiffness: 200, damping: 20, delay: 0.1 }}
className="relative mb-8"
>
<motion.div
// REDUCED('ambient') → off: the infinite glow loop must not run for
// reduced-motion users (A2 V1' catch). 2s/easeInOut are justified
// literals — ambient breathing has no DUR token by design.
animate={reduceMotion ? undefined : { boxShadow: ["0 0 0px hsl(var(--primary) / 0)", "0 0 40px hsl(var(--primary) / 0.3)", "0 0 0px hsl(var(--primary) / 0)"] }}
transition={reduceMotion ? undefined : { duration: 2, repeat: Infinity, ease: "easeInOut" }}
className="rounded-3xl"
>
<img
src={waggleLogo}
alt="Waggle AI"
width={80}
height={80}
className="w-20 h-20 rounded-3xl shadow-2xl"
/>
</motion.div>
</motion.div>
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-2xl font-display font-bold text-foreground mb-1"
>
Waggle AI
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="text-xs text-muted-foreground mb-10 font-display"
>
Autonomous Agent OS
</motion.p>
{/* Progress bar */}
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 240 }}
// Boot cinematic beat: the 0.3s reveal + 0.4s delay ride the boot
// surface's own timeline (glow loop + staggered logo/title reveals),
// deliberately off the standard DUR grid.
transition={{ delay: 0.4, duration: 0.3 }}
className="h-1 rounded-full bg-muted overflow-hidden mb-4"
>
<motion.div
className="h-full bg-primary rounded-full"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
// R20 Lane CL (item 1): under prefers-reduced-motion the progress area
// must not animate — snap the fill to each step instantly (still shows
// progress, no lingering transition). Full ease otherwise.
transition={reduceMotion ? { duration: 0 } : { duration: DUR.settle, ease: EASE_OUT }}
/>
</motion.div>
{/* Phase text */}
<div className="h-5">
<AnimatePresence mode="wait">
<motion.p
key={phase}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: DUR.base }}
className={`text-xs font-mono ${
phase === PHASES.length - 1 ? "text-[var(--honey-text)]" : "text-muted-foreground"
}`}
>
{PHASES[phase]}
</motion.p>
</AnimatePresence>
</div>
{/* Phase dots */}
<div className="flex gap-2 mt-6">
{PHASES.map((_, i) => (
<motion.div
key={i}
className={`w-1.5 h-1.5 rounded-full ${
i <= phase ? "bg-primary" : "bg-muted-foreground/30"
}`}
// R20 Lane CL (item 1): the active-dot pulse is a repeating keyframe
// loop — gate it under reduced motion so the progress area carries
// ZERO lingering animation (the logo glow loop was gated in A2; the
// background radial is a static div). Reduced motion → no pulse.
animate={!reduceMotion && i === phase ? { scale: [1, 1.4, 1] } : {}}
transition={{ duration: DUR.settle }}
/>
))}
</div>
{/* Skip hint */}
<AnimatePresence>
{showSkipHint && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// Boot cinematic beat: 0.3s hint fade, intentionally off the DUR grid
// (part of the boot surface's bespoke timeline).
transition={{ duration: 0.3 }}
className="absolute bottom-8 text-xs text-muted-foreground/50"
>
Click or press any key to skip
</motion.p>
)}
</AnimatePresence>
</motion.div>
);
};
export default BootScreen;

View File

@@ -0,0 +1,181 @@
/**
* UX Refactor v2.1 P1a Stage B — ChatHost keep-alive (conversion plan §4.2,
* deviation §9.12).
*
* Mounts ONE ChatWindowInstance (component untouched) per workspace VISITED
* this session (i.e. whose /workspaces/:id/chat route has been active), keyed
* by workspaceId, and keeps it ALIVE — hidden, not unmounted — when the route
* is elsewhere, so in-flight useChat SSE streams survive navigation. This is
* the conversion's only behavioral guarantee carried over from windowing: an
* agent run must survive the user navigating to /memory and back.
*
* Mechanism — portal container swap: each instance renders through a React
* portal into a stable per-workspace container <div>. The container's DOM
* parent swaps between a hidden module-level holding element and the chat-tab
* slot (`ChatSlot` — the §5.2 seam-b node WorkspaceRoute passes into
* WorkspaceDesktopApp's `chatSlot` prop) whenever /workspaces/:id/chat is
* active. Re-parenting a portal container moves DOM without remounting the
* React subtree, so component state, timers and SSE streams are preserved.
*
* Stage C mounts this with a one-liner inside AppShell's <main>: <ChatHost />.
* ChatWindowInstance props are byte-identical to Desktop.tsx:341-358, sourced
* from useChatWidgetState + ShellContext (§4.2); the window's stamped
* workspaceName/templateLabel resolve live from the workspaces list instead.
*/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { matchPath, useLocation } from 'react-router-dom';
import ChatWindowInstance from './apps/ChatWindowInstance';
import { useShell } from '@/providers/ShellContext';
import {
rekeyLocalDefaultChatState,
takeChatSeed,
useChatWidgetState,
type ChatSeed,
} from '@/hooks/useChatWidgetState';
// ── Portal container registry ─────────────────────────────────────────────
// Module-level so ChatSlot can adopt containers without coupling to
// ChatHost's render cycle (the slot may mount before or after the host).
const containers = new Map<string, HTMLDivElement>();
let holdingHost: HTMLDivElement | null = null;
/** Hidden off-screen parent for containers no slot currently claims. */
function getHoldingHost(): HTMLDivElement {
if (!holdingHost) {
holdingHost = document.createElement('div');
holdingHost.setAttribute('data-chat-host-holding', 'true');
holdingHost.style.display = 'none';
document.body.appendChild(holdingHost);
}
return holdingHost;
}
/** Stable per-workspace portal container; parked in the holding host until a ChatSlot adopts it. */
function getChatContainer(workspaceId: string): HTMLDivElement {
let el = containers.get(workspaceId);
if (!el) {
el = document.createElement('div');
el.setAttribute('data-chat-container', workspaceId);
el.style.height = '100%';
getHoldingHost().appendChild(el);
containers.set(workspaceId, el);
}
return el;
}
/** Return a container to the hidden holding host (slot unmounted or switched workspace). */
function parkChatContainer(workspaceId: string): void {
const el = containers.get(workspaceId);
if (el && el.parentElement !== getHoldingHost()) {
getHoldingHost().appendChild(el);
}
}
// ── ChatSlot — the §5.2 seam-b node ───────────────────────────────────────
/**
* The chat-tab slot WorkspaceRoute passes into WorkspaceDesktopApp's
* `chatSlot` prop. On mount it adopts the workspace's portal container
* (re-parenting, not remounting); on unmount it parks the container back in
* the hidden holding host so the widget keeps running off-route.
*/
export const ChatSlot = ({ workspaceId }: { workspaceId: string }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current?.appendChild(getChatContainer(workspaceId));
return () => parkChatContainer(workspaceId);
}, [workspaceId]);
return <div ref={ref} className="h-full" data-testid="chat-widget-slot" data-workspace-id={workspaceId} />;
};
// ── Per-workspace widget instance ─────────────────────────────────────────
const ChatHostInstance = ({ workspaceId }: { workspaceId: string }) => {
const { workspaces, defaultAutonomy, setContextRailTarget } = useShell();
const ws = workspaces.find(w => w.id === workspaceId);
const { entry, setPersona, setAutonomy } = useChatWidgetState(workspaceId, { defaultAutonomy });
// §4.2 one-shot seed: taken exactly once on this widget's first mount.
// Lazy ref init survives StrictMode double-render, and the instance never
// remounts while visited (keep-alive), so the seed cannot replay.
const seedRef = useRef<ChatSeed | null | undefined>(undefined);
if (seedRef.current === undefined) seedRef.current = takeChatSeed(workspaceId) ?? null;
const seed = seedRef.current;
// Persist the seeded persona the way openChatForWorkspace stamped
// personaOverride onto the new window (useWindowManager.ts:230,257).
// Mount-only, mirroring window creation.
useEffect(() => {
if (seed?.personaId) setPersona(seed.personaId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Window-creation parity for the starting persona (useWindowManager.ts:230):
// widget state first, then the seed override, then the workspace's persona.
const personaId = entry.personaId ?? seed?.personaId ?? ws?.persona;
return createPortal(
<div className="h-full flex flex-col" data-testid={`chat-widget-${workspaceId}`}>
{/* UX gold-standard H1: the composeChatTitle breadcrumb row is gone —
it duplicated workspace + persona info already shown in the
WorkspaceDesktopApp header and ChatApp's agent chip row. */}
<div className="flex-1 min-h-0">
<ChatWindowInstance
workspaceId={workspaceId}
workspaceName={ws?.name}
templateId={ws?.templateId}
storageType={ws?.storageType}
initialPersona={personaId}
initialMessage={seed?.initialMessage}
autoSendInitial={seed?.autoSend ?? false}
onPersonaChange={setPersona}
autonomyLevel={entry.autonomyLevel ?? 'normal'}
autonomyExpiresAt={entry.autonomyExpiresAt ?? null}
onAutonomyChange={setAutonomy}
onContextRail={(target) => setContextRailTarget({ ...target, workspaceId })}
/>
</div>
</div>,
getChatContainer(workspaceId),
);
};
// ── ChatHost ──────────────────────────────────────────────────────────────
const ChatHost = () => {
const location = useLocation();
const { workspaces } = useShell();
const [visited, setVisited] = useState<string[]>([]);
// §3.3.3: one-shot 'local-default' placeholder re-key when the store first
// sees the REAL workspace list — mirrors the deleted reconciliation sweep
// (useWindowManager.ts:155-183), incl. its skip of the pre-fetch
// placeholder-only list.
const rekeyedRef = useRef(false);
useEffect(() => {
if (rekeyedRef.current) return;
if (workspaces.length === 0) return;
if (workspaces.length === 1 && workspaces[0].id === 'local-default') return;
rekeyedRef.current = true;
rekeyLocalDefaultChatState(workspaces[0].id);
}, [workspaces]);
// A workspace becomes "visited" when its chat tab route is active; its
// instance then stays mounted for the rest of the session (keep-alive).
useEffect(() => {
const match = matchPath('/workspaces/:workspaceId/chat', location.pathname);
const wsId = match?.params.workspaceId;
if (!wsId || wsId === 'local-default') return;
setVisited(prev => (prev.includes(wsId) ? prev : [...prev, wsId]));
}, [location.pathname]);
return (
<>
{visited.map(wsId => <ChatHostInstance key={wsId} workspaceId={wsId} />)}
</>
);
};
export default ChatHost;

View File

@@ -0,0 +1,106 @@
import { useState, useEffect, useRef } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { isActionItem, actionIndexForRenderItem } from '../../lib/context-menu-index';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: boolean;
}
interface ContextMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
onClose: () => void;
/**
* Wave W Lane B (item 2, opt-in — default-preserving): when set, the menu
* scales in FROM this transform-origin corner (150ms scale 0.96→1 + fade) so
* it reads as growing out of the trigger, and adopts the roomier "comfortable"
* item density (matching the marketplace row spacing). Callers that omit it
* render exactly as before. Set by WorkspaceActionsMenu to the kebab corner.
*/
origin?: string;
}
const ContextMenu = ({ items, position, onClose, origin }: ContextMenuProps) => {
const ref = useRef<HTMLDivElement>(null);
const [focusIndex, setFocusIndex] = useState(-1);
const reduceMotion = useReducedMotion();
const cornered = origin !== undefined;
// Reduced motion drops the scale (fade only) for the cornered entrance; every
// other consumer keeps its existing behavior untouched.
const enterScale = cornered && !reduceMotion ? 0.96 : cornered ? 1 : 0.95;
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
useEffect(() => {
const actionItems = items.filter(isActionItem);
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
setFocusIndex(i => (i + 1) % actionItems.length);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setFocusIndex(i => (i - 1 + actionItems.length) % actionItems.length);
}
if (e.key === 'Enter' && focusIndex >= 0) {
actionItems[focusIndex]?.onClick();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [focusIndex, items, onClose]);
const style: React.CSSProperties = {
position: 'fixed',
left: Math.min(position.x, window.innerWidth - 200),
top: Math.min(position.y, window.innerHeight - items.length * 32 - 16),
zIndex: 9999,
};
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: enterScale }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: enterScale }}
transition={{ duration: cornered ? 0.15 : 0.1 }}
style={cornered ? { ...style, transformOrigin: origin } : style}
className="min-w-[160px] py-1 rounded-xl glass-strong border border-border/50 shadow-xl"
>
{items.map((item, i) => {
if (item.separator) return <div key={i} className="my-1 h-px bg-border/30" />;
const currentActionIndex = actionIndexForRenderItem(items, i);
return (
<button
key={i}
onClick={() => { item.onClick(); onClose(); }}
disabled={item.disabled}
className={`w-full flex items-center text-xs text-left transition-colors
${cornered ? 'gap-2.5 px-3.5 py-2' : 'gap-2 px-3 py-1.5'}
${item.danger ? 'text-destructive hover:bg-destructive/10' : 'text-foreground hover:bg-muted/50'}
${item.disabled ? 'opacity-40 cursor-not-allowed' : ''}
${focusIndex === currentActionIndex ? 'bg-muted/50 ring-1 ring-inset ring-[var(--focus-ring)]' : ''}`}
>
{item.icon && <span className="w-3.5 h-3.5 flex items-center justify-center">{item.icon}</span>}
{item.label}
</button>
);
})}
</motion.div>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,214 @@
/**
* EmbeddingRoutingCard — pick the memory embedding provider (steal #10).
*
* Sits under ModelPilotCard in Settings Models. Mirrors ModelPilotCard's Hive DS
* styling and key-gating pattern. The picker offers `auto` + the tier-allowed
* providers; voyage/openai are disabled when their vault key is missing. A live
* badge shows what is actually running; a Reprobe button re-runs the probe (useful
* after installing Ollama or adding a key). Because the live embedder is fixed at
* boot, an explicit switch shows a "restart to apply" hint honestly.
*/
import { useState, useEffect, useCallback } from 'react';
import { Boxes, RotateCw, Info, AlertTriangle } from 'lucide-react';
import { adapter, AdapterHttpError, type EmbeddingRoutingStatus } from '@/lib/adapter';
import type { Provider } from '@/hooks/useProviders';
import { TIER_CAPABILITIES, type Tier } from '@waggle/shared';
import { HintTooltip } from '@/components/ui/hint-tooltip';
interface EmbeddingRoutingCardProps {
/** For voyage/openai key-gating (mirrors ModelPilotCard). */
providers: Provider[];
/** Gates which providers the tier may select. */
tier: Tier;
}
/** Display order for the picker; 'mock' is never user-selectable. */
const PROVIDER_ORDER = ['auto', 'inprocess', 'ollama', 'voyage', 'openai', 'litellm'] as const;
const PROVIDER_LABELS: Record<string, string> = {
auto: 'Auto (recommended)',
inprocess: 'In-process (local, bundled)',
ollama: 'Ollama (local server)',
voyage: 'Voyage (cloud, paid)',
openai: 'OpenAI (cloud, paid)',
litellm: 'LiteLLM (proxy)',
mock: 'Mock (no semantics)',
};
const EmbeddingRoutingCard = ({ providers, tier }: EmbeddingRoutingCardProps) => {
const [status, setStatus] = useState<EmbeddingRoutingStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [restartHint, setRestartHint] = useState(false);
const load = useCallback(async () => {
try {
setStatus(await adapter.getEmbeddingStatus());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load embedding status');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
// Tier-allowed providers (+ always 'auto'), in display order, minus 'mock'.
const allowed = new Set(TIER_CAPABILITIES[tier].embeddingProviders as readonly string[]);
const options = PROVIDER_ORDER.filter(p => p === 'auto' || (p !== ('mock' as string) && allowed.has(p)));
/** True when a cloud provider's key is missing (disable + hint). */
const keyMissing = (id: string): boolean => {
if (id === 'openai') {
const p = providers.find(pr => pr.id === 'openai');
return !!p && p.requiresKey && !p.hasKey;
}
if (id === 'voyage') {
// Voyage is embedding-only (no /api/providers entry) — use the live probe.
return !(
status?.availableProviders?.includes('voyage') ||
status?.activeProvider === 'voyage' ||
status?.configuredProvider === 'voyage'
);
}
return false;
};
const handleChange = async (provider: string) => {
setBusy(true);
setError(null);
setRestartHint(false);
try {
const next = await adapter.setEmbeddingProvider(provider);
setStatus(next);
setRestartHint(next.restartRequired === true);
} catch (err) {
const msg = err instanceof AdapterHttpError ? err.message
: err instanceof Error ? err.message : 'Could not change provider';
setError(msg);
} finally {
setBusy(false);
}
};
const handleReprobe = async () => {
setBusy(true);
setError(null);
try {
setStatus(await adapter.reprobeEmbedding());
} catch (err) {
setError(err instanceof Error ? err.message : 'Reprobe failed');
} finally {
setBusy(false);
}
};
const isMock = status?.activeProvider === 'mock';
const envOverride = status?.envOverride === true;
return (
<div className="rounded-xl bg-secondary/30 border border-border/30 p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4 text-honey" />
<h3 className="text-sm font-display font-semibold text-foreground">Memory Embeddings</h3>
<HintTooltip content="Which model turns your memories into vectors for semantic search. Local options keep everything on-device; cloud options are higher quality but send text to the provider.">
<button
type="button"
aria-label="About memory embeddings"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Info className="w-3.5 h-3.5" />
</button>
</HintTooltip>
</div>
<HintTooltip content="Re-check which providers are reachable (e.g. after starting Ollama or adding a key)">
<button
type="button"
onClick={handleReprobe}
disabled={busy || loading}
data-testid="embedding-reprobe"
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-display bg-muted/50 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
<RotateCw className={`w-3 h-3 ${busy ? 'animate-spin' : ''}`} />
Reprobe
</button>
</HintTooltip>
</div>
{/* Live status badge */}
<div className="flex items-center justify-between rounded-lg border border-[var(--line-soft)] bg-card px-3 py-2">
<span className="text-[11px] text-muted-foreground">Active provider</span>
{loading ? (
<span className="text-[11px] text-muted-foreground">Loading</span>
) : (
<span className="flex items-center gap-2 text-xs">
<span
className={`font-display font-semibold ${isMock ? 'text-[var(--status-warning)]' : 'text-foreground'}`}
data-testid="embedding-active-provider"
>
{PROVIDER_LABELS[status?.activeProvider ?? ''] ?? status?.activeProvider ?? '—'}
</span>
{status?.modelName && (
<span className="text-muted-foreground">
{status.modelName}{status.dimensions ? ` · ${status.dimensions}d` : ''}
</span>
)}
</span>
)}
</div>
{isMock && !loading && (
<div className="flex items-start gap-1.5 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/10 px-2.5 py-2 text-[11px] text-muted-foreground">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-[var(--status-warning)] mt-px" />
<span>Running the deterministic mock embedder semantic search returns noise. Pick a real provider below, then restart.</span>
</div>
)}
{/* Provider picker */}
<div>
<label className="text-[11px] text-muted-foreground block mb-1.5" htmlFor="embedding-provider-select">
Provider
</label>
<select
id="embedding-provider-select"
data-testid="embedding-provider-select"
value={status?.configuredProvider ?? 'auto'}
disabled={busy || loading || envOverride}
onChange={(e) => void handleChange(e.target.value)}
className="w-full bg-muted/50 border border-border/30 rounded-lg px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30 disabled:opacity-50"
>
{options.map(p => {
const missing = keyMissing(p);
return (
<option key={p} value={p} disabled={missing}>
{PROVIDER_LABELS[p]}{missing ? ' — add key in Vault' : ''}
</option>
);
})}
</select>
</div>
{/* Hints */}
{envOverride && (
<p className="text-[11px] text-[var(--status-warning)]" data-testid="embedding-env-hint">
Set by the <span className="font-mono">EMBEDDING_PROVIDER</span> environment variable changes here are ignored until it is unset.
</p>
)}
{restartHint && !envOverride && (
<p className="text-[11px] text-muted-foreground" data-testid="embedding-restart-hint">
Saved. Restart Waggle to switch the running embedder to this provider.
</p>
)}
{error && (
<p className="text-[11px] text-destructive" data-testid="embedding-error">{error}</p>
)}
</div>
);
};
export default EmbeddingRoutingCard;

View File

@@ -0,0 +1,52 @@
import { Component, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
interface Props {
children: ReactNode;
appName: string;
onClose?: () => void;
}
interface State {
hasError: boolean;
error?: Error;
}
class AppErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error(`[${this.props.appName}] Render error:`, error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
<AlertTriangle className="w-8 h-8 text-destructive/60" />
<p className="text-sm font-display font-medium text-foreground">
{this.props.appName} encountered an error
</p>
<p className="text-xs text-muted-foreground max-w-xs">
{this.state.error?.message || 'Something went wrong'}
</p>
{this.props.onClose && (
<button
onClick={this.props.onClose}
className="mt-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-muted hover:bg-muted/80 transition-colors"
>
Close Window
</button>
)}
</div>
);
}
return this.props.children;
}
}
export default AppErrorBoundary;

View File

@@ -0,0 +1,30 @@
import { Lock, ArrowUpRight } from 'lucide-react';
interface LockedFeatureProps {
featureName: string;
upgradePrompt: string;
children?: React.ReactNode;
}
const LockedFeature = ({ featureName, upgradePrompt, children }: LockedFeatureProps) => (
<div className="relative h-full">
{children && (
<div className="h-full opacity-20 pointer-events-none select-none blur-[1px]">
{children}
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center">
<Lock className="w-5 h-5 text-muted-foreground" />
</div>
<p className="text-sm font-display font-medium text-foreground">{featureName}</p>
<p className="text-xs text-muted-foreground max-w-xs">{upgradePrompt}</p>
<button className="flex items-center gap-1.5 mt-1 px-4 py-2 text-xs font-medium rounded-lg bg-primary/20 text-honey hover:bg-primary/30 transition-colors">
<ArrowUpRight className="w-3.5 h-3.5" />
View Plans
</button>
</div>
</div>
);
export default LockedFeature;

View File

@@ -0,0 +1,48 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { Provider } from '@/hooks/useProviders';
import ModelPilotCard from './ModelPilotCard';
const PROVIDERS: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
hasKey: true,
badge: null,
keyUrl: null,
requiresKey: true,
models: [
{ id: 'gpt-5', name: 'GPT-5', cost: '$$$', speed: 'fast' },
{ id: 'gpt-5-mini', name: 'GPT-5 mini', cost: '$$', speed: 'fast' },
{ id: 'gpt-5-nano', name: 'GPT-5 nano', cost: '$', speed: 'fast' },
],
},
];
describe('ModelPilotCard', () => {
it('names the budget threshold slider and preserves update behavior', () => {
const onUpdate = vi.fn();
render(
<TooltipProvider>
<ModelPilotCard
defaultModel="gpt-5"
fallbackModel="gpt-5-mini"
budgetModel="gpt-5-nano"
budgetThreshold={0.6}
dailyBudget={20}
providers={PROVIDERS}
onUpdate={onUpdate}
/>
</TooltipProvider>,
);
const threshold = screen.getByRole('slider', { name: /budget saver activation threshold/i });
expect(threshold).toHaveAttribute('name', 'budgetThreshold');
expect(threshold.className).toContain('focus-visible:ring-2');
fireEvent.change(threshold, { target: { value: '0.75' } });
expect(onUpdate).toHaveBeenCalledWith({ budgetThreshold: 0.75 });
});
});

View File

@@ -0,0 +1,482 @@
/**
* ModelPilotCard — 3-lane model selector (Primary / Fallback / Budget Saver).
*
* Displays a visual model fallback chain so users can see how their models
* cascade: Primary → Fallback → Budget Saver (when daily spend is high).
*
* Does NOT save — the parent SettingsApp handles persistence.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Zap, Shield, Coins, ChevronDown, Info, ToggleLeft, ToggleRight, Key,
} from 'lucide-react';
import type { Provider } from '@/hooks/useProviders';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { formatModelLabel } from '@/lib/model-label';
interface ModelPilotCardProps {
defaultModel: string;
fallbackModel: string | null;
budgetModel: string | null;
budgetThreshold: number;
dailyBudget: number | null;
providers: Provider[];
onUpdate: (fields: {
defaultModel?: string;
fallbackModel?: string | null;
budgetModel?: string | null;
budgetThreshold?: number;
}) => void;
}
interface LaneConfig {
key: 'primary' | 'fallback' | 'budget';
label: string;
icon: React.ElementType;
/** Role accent (warm palette token) — drives the left rail + label text only;
* the row surface itself stays neutral (H2 fix: no full-row tints). */
rail: string;
description: string;
}
const LANES: LaneConfig[] = [
{
key: 'primary',
label: 'Primary',
icon: Zap,
// Wave U Lane E (item 2): --honey-text (not raw --honey) so the 11px label
// clears AA in light — raw --honey #c07f00 probes ~3.3:1 on the ivory card,
// --honey-text #9a6408 is ~4.9:1. No-op in dark (both resolve to #e9a52c).
rail: 'var(--honey-text)',
description: 'Your default model for all tasks',
},
{
key: 'fallback',
label: 'Fallback',
icon: Shield,
// Warm copper — role identity deliberately OFF the semantic palette
// (round-4: the --risk rail read as "this lane is failing"). Mixed from
// the theme tokens so it tracks both themes; the Shield icon carries the
// role, and red/green stay reserved for real states. Wave U Lane E (item 2):
// the honey half uses --honey-text so the copper label clears AA in light
// (~3.8:1 → ~4.7:1); no-op in dark where --honey-text == --honey.
rail: 'color-mix(in srgb, var(--honey-text) 55%, var(--risk) 45%)',
description: 'Used when primary is down or rate-limited',
},
{
key: 'budget',
label: 'Budget Saver',
icon: Coins,
// Warm sand/stone — NOT --healthy (green read as "success", not a role).
rail: 'var(--text-muted)',
description: 'Activates when daily spend exceeds threshold',
},
];
const COST_TOOLTIPS: Record<string, string> = {
'$': '~$0.001/msg',
'$$': '~$0.01/msg',
'$$$': '~$0.05/msg',
};
/** Dropdown for picking a model, grouped by provider */
const LaneDropdown = ({
providers,
value,
onChange,
onClose,
sameAsPrimaryId,
}: {
providers: Provider[];
value: string | null;
onChange: (modelId: string | null) => void;
onClose: () => void;
/** W2C: model id equal to Primary — disabled here (a fallback == primary can never fire). */
sameAsPrimaryId?: string;
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
return (
<div
ref={ref}
className="absolute top-full left-0 right-0 mt-1 z-50 bg-card border border-border rounded-xl shadow-lg max-h-56 overflow-auto"
>
{providers.map(provider => (
<div key={provider.id}>
<div className="px-3 py-1 bg-muted/30 flex items-center gap-1.5 sticky top-0">
<span className="text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider">
{provider.name}
</span>
{!provider.hasKey && provider.requiresKey && (
<span className="flex items-center gap-0.5 text-[11px] text-[var(--status-warning)]">
<Key className="w-2.5 h-2.5" /> No key
</span>
)}
{/* ✓ = key configured (presence only — no probe status is in reach
here), so it stays muted-neutral rather than a success green. */}
{provider.hasKey && (
<span className="text-[11px] text-muted-foreground">&#10003;</span>
)}
</div>
{provider.models.map(m => {
const isFree = m.id.includes(':free');
const isSameAsPrimary = m.id === sameAsPrimaryId;
const disabled = (!provider.hasKey && provider.requiresKey) || isSameAsPrimary;
return (
<button
key={m.id}
onClick={() => { onChange(m.id); onClose(); }}
disabled={disabled}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center justify-between ${
value === m.id
? 'bg-primary/10 text-honey'
: disabled
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-foreground hover:bg-muted/50'
}`}
>
<span className="flex items-center gap-1.5">
{m.name}
{isFree && (
<span className="px-1 py-0.5 rounded text-[11px] font-display font-bold bg-[var(--healthy-wash)] text-[var(--healthy)] leading-none">
FREE
</span>
)}
</span>
<span className="flex items-center gap-1.5 text-[11px]">
{isSameAsPrimary ? (
<span className="text-muted-foreground/60">Same as Primary</span>
) : disabled ? (
// Wave X Lane B: /40 read as broken; /60 matches the "Same as
// Primary" sibling above (disabled-state label — WCAG-inactive
// exempt, but should still be legible).
<span className="text-muted-foreground/60">Add key in Vault</span>
) : (
<HintTooltip content={COST_TOOLTIPS[m.cost] ?? ''}>
{/* The $ count already encodes cost — neutral text, no traffic-light colors. */}
<span className="text-muted-foreground" tabIndex={0}>
{m.cost}
</span>
</HintTooltip>
)}
</span>
</button>
);
})}
</div>
))}
</div>
);
};
/** Resolve display name for a model id (W2C: via the shared formatter). */
const resolveModelName = (modelId: string | null, providers: Provider[]): string =>
modelId ? formatModelLabel(modelId, providers) : 'Not set';
/** Resolve cost tier for a model id */
const resolveModelCost = (modelId: string | null, providers: Provider[]): string | null => {
if (!modelId) return null;
for (const p of providers) {
const found = p.models.find(m => m.id === modelId);
if (found) return found.cost;
}
return null;
};
/** Cost rank for a model — lower is cheaper. FREE=0, $=1, $$=2, $$$=3; an
* unknown/unpriced cost returns null (excluded from cheaper-than comparisons). */
const costRank = (model: { id: string; cost: string }): number | null => {
if (model.id.includes(':free')) return 0;
switch (model.cost) {
case '$': return 1;
case '$$': return 2;
case '$$$': return 3;
default: return null;
}
};
/** Find a strictly-cheaper, USABLE model than the primary from the live catalog
* (real data only — the owning provider must have a key so the fallback can
* actually fire, and it must not equal the primary). Returns the cheapest such
* model, or null when none exists (no invention — the button then hides). */
const findCheaperFallback = (
defaultModel: string,
providers: Provider[],
): { id: string; name: string } | null => {
let primaryRank: number | null = null;
for (const p of providers) {
const m = p.models.find(mm => mm.id === defaultModel);
if (m) { primaryRank = costRank(m); break; }
}
if (primaryRank == null) return null;
let best: { id: string; name: string; rank: number } | null = null;
for (const p of providers) {
if (p.requiresKey && !p.hasKey) continue; // must be usable
for (const m of p.models) {
if (m.id === defaultModel) continue;
const rank = costRank(m);
if (rank == null || rank >= primaryRank) continue;
if (!best || rank < best.rank) best = { id: m.id, name: m.name, rank };
}
}
return best ? { id: best.id, name: best.name } : null;
};
const ModelPilotCard = ({
defaultModel,
fallbackModel,
budgetModel,
budgetThreshold,
dailyBudget,
providers,
onUpdate,
}: ModelPilotCardProps) => {
const [singleMode, setSingleMode] = useState(false);
const [openLane, setOpenLane] = useState<string | null>(null);
const [showInfo, setShowInfo] = useState(false);
const handleClose = useCallback(() => setOpenLane(null), []);
const getModelForLane = (lane: LaneConfig['key']): string | null => {
switch (lane) {
case 'primary': return defaultModel || null;
case 'fallback': return fallbackModel;
case 'budget': return budgetModel;
}
};
const handleLaneChange = (lane: LaneConfig['key'], modelId: string | null) => {
switch (lane) {
case 'primary':
onUpdate({ defaultModel: modelId ?? '' });
break;
case 'fallback':
onUpdate({ fallbackModel: modelId });
break;
case 'budget':
onUpdate({ budgetModel: modelId });
break;
}
};
const toggleSingleMode = () => {
const next = !singleMode;
setSingleMode(next);
if (next) {
// Clear fallback & budget when going to single mode
onUpdate({ fallbackModel: null, budgetModel: null });
}
};
const visibleLanes = singleMode ? LANES.slice(0, 1) : LANES;
// kw (Wave S): when the fallback can never fire (== primary), offer a
// one-click switch to a strictly-cheaper usable model — only if one really
// exists in the catalog. Null hides the suggestion (no invented models).
const cheaperFallback =
!singleMode && fallbackModel && fallbackModel === defaultModel
? findCheaperFallback(defaultModel, providers)
: null;
return (
<div className="rounded-xl bg-secondary/30 border border-border/30 p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-honey" />
<h3 className="text-sm font-display font-semibold text-foreground">Model Pilot</h3>
<HintTooltip content="What is Model Pilot?">
<button
type="button"
aria-label={showInfo ? 'Hide Model Pilot details' : 'Show Model Pilot details'}
aria-expanded={showInfo}
onClick={() => setShowInfo(!showInfo)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Info className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</HintTooltip>
</div>
<HintTooltip content={singleMode ? 'Enable fallback chain' : 'Use single model only'}>
<button
onClick={toggleSingleMode}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
{singleMode ? (
<ToggleLeft className="w-4 h-4" />
) : (
<ToggleRight className="w-4 h-4 text-honey" />
)}
{singleMode ? 'Single model' : 'Fallback chain'}
</button>
</HintTooltip>
</div>
{/* Info tooltip */}
{showInfo && (
<div className="p-2.5 rounded-lg bg-primary/5 border border-primary/10 text-[11px] text-muted-foreground leading-relaxed">
<strong className="text-foreground">Model Pilot</strong> automatically routes your requests through a fallback chain.
If your primary model is unavailable (rate limit, outage), it falls back to your secondary.
The budget saver activates when your daily spend crosses the threshold, switching to a cheaper model
to keep costs predictable.
</div>
)}
{/* Lanes */}
<div className="space-y-2">
{visibleLanes.map(lane => {
const modelId = getModelForLane(lane.key);
const modelName = resolveModelName(modelId, providers);
const cost = resolveModelCost(modelId, providers);
const isFree = modelId?.includes(':free') ?? false;
const isOpen = openLane === lane.key;
return (
<div key={lane.key} className="relative rounded-lg border border-[var(--line-soft)] bg-card p-2.5 pl-3.5 shadow-[var(--shadow-sm)]">
{/* Single role accent: inset 3px rail + colored label (no overflow-hidden —
the LaneDropdown below overhangs the row). */}
<span
aria-hidden
className="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-full"
style={{ background: lane.rail }}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<lane.icon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-[11px] font-display font-semibold" style={{ color: lane.rail }}>
{lane.label}
</p>
<p className="text-[11px] text-muted-foreground truncate">{lane.description}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Current model display */}
<div className="text-right">
<p className="text-xs text-foreground font-display truncate max-w-[140px]">
{modelName}
</p>
<div className="flex items-center justify-end gap-1">
{cost && (
// R10: the bare `$$$` glyph read as cryptic — surface the
// explicit per-message cost inline (+ aria-label) so the
// tier is legible without a hover or a foot-of-card legend.
<span
className="text-[11px] text-muted-foreground"
aria-label={`Cost tier ${cost}${COST_TOOLTIPS[cost] ? `${COST_TOOLTIPS[cost]}` : ''}`}
>
{cost}{COST_TOOLTIPS[cost] ? ` · ${COST_TOOLTIPS[cost]}` : ''}
</span>
)}
{isFree && (
<span className="px-1 rounded text-[11px] font-display font-bold bg-[var(--healthy-wash)] text-[var(--healthy)] leading-none">
FREE
</span>
)}
</div>
</div>
{/* Change button */}
<button
onClick={() => setOpenLane(isOpen ? null : lane.key)}
className="flex items-center gap-0.5 px-2 py-1 rounded-md text-[11px] font-display bg-muted/50 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
Change
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
{/* Dropdown */}
{isOpen && (
<LaneDropdown
providers={providers}
value={modelId}
onChange={(id) => handleLaneChange(lane.key, id)}
onClose={handleClose}
sameAsPrimaryId={lane.key === 'fallback' ? (defaultModel || undefined) : undefined}
/>
)}
</div>
);
})}
</div>
{/* W2C: a persisted fallback equal to the primary can never fire
(chat.ts guards resolvedModel !== fallbackModel). Warn + one-click clear.
H2: quiet neutral styling — the ModelGate key-health banner above owns
the amber on this screen; two amber banners at once read as an incident. */}
{!singleMode && fallbackModel && fallbackModel === defaultModel && (
<div
className="flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg border border-[var(--line-soft)] bg-muted/40 px-2.5 py-2 text-[11px] text-muted-foreground"
data-testid="model-pilot-fallback-equals-primary"
>
<Shield className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 min-w-[10rem]">Fallback equals Primary failover will never trigger.</span>
{cheaperFallback && (
<button
onClick={() => onUpdate({ fallbackModel: cheaperFallback.id })}
title={`Switch fallback to ${cheaperFallback.name}`}
data-testid="model-pilot-use-cheaper-fallback"
className="shrink-0 font-display font-semibold text-honey transition-opacity hover:opacity-80"
>
Use a cheaper fallback
</button>
)}
<button
onClick={() => onUpdate({ fallbackModel: null })}
className="shrink-0 font-display font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Clear
</button>
</div>
)}
{/* Budget threshold slider — only when budget lane visible & daily budget is set */}
{!singleMode && dailyBudget != null && dailyBudget > 0 && (
<div className="pt-2 border-t border-border/20">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[11px] text-muted-foreground">
Budget saver activates at <strong className="text-foreground">{Math.round(budgetThreshold * 100)}%</strong> of daily budget
</p>
<p className="text-[11px] text-muted-foreground font-mono">
${(dailyBudget * budgetThreshold).toFixed(2)} / ${dailyBudget.toFixed(2)}
</p>
</div>
<input
aria-label="Budget saver activation threshold"
name="budgetThreshold"
type="range"
min={0.1}
max={1.0}
step={0.05}
value={budgetThreshold}
onChange={(e) => onUpdate({ budgetThreshold: parseFloat(e.target.value) })}
className="w-full h-1.5 rounded-full appearance-none bg-muted/50 accent-[var(--honey)] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<div className="flex justify-between text-[11px] text-muted-foreground mt-0.5">
<span>10%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
)}
{/* Cost legend removed (R10): each lane row now carries the explicit
per-message cost inline, so a foot-of-card key is redundant. */}
</div>
);
};
export default ModelPilotCard;

View File

@@ -0,0 +1,169 @@
/**
* ModelSelector — reusable model picker used everywhere:
* Settings, Onboarding, Workspace creation, Spawn dialog, Agent config.
*
* Fetches from /api/providers (via useProviders hook).
* Shows models grouped by provider with key status indicators.
*/
import { useState } from 'react';
import { ChevronDown, Key, AlertTriangle, Zap, Timer, Turtle, type LucideIcon } from 'lucide-react';
import type { Provider, ProviderModel } from '@/hooks/useProviders';
import { formatModelLabel } from '@/lib/model-label';
interface ModelSelectorProps {
value: string;
onChange: (modelId: string) => void;
providers: Provider[];
/** Show as compact dropdown (default) or expanded card grid */
variant?: 'dropdown' | 'cards';
/** Filter to only show providers with keys */
onlyAvailable?: boolean;
/** Optional class name */
className?: string;
}
const COST_COLORS: Record<string, string> = {
'$': 'text-emerald-400',
'$$': 'text-amber-400',
'$$$': 'text-rose-400',
};
// Lucide, not emoji — one icon language across the chrome (2026-07-06 P2).
const SPEED_ICONS: Record<string, { icon: LucideIcon; label: string }> = {
fast: { icon: Zap, label: 'Fast' },
medium: { icon: Timer, label: 'Medium speed' },
slow: { icon: Turtle, label: 'Slower' },
};
function SpeedGlyph({ speed }: { speed: string }) {
const entry = SPEED_ICONS[speed];
if (!entry) return null;
const Icon = entry.icon;
return <Icon className="w-3 h-3 inline text-muted-foreground" aria-label={entry.label} />;
}
const ModelSelector = ({ value, onChange, providers, variant = 'dropdown', onlyAvailable = false, className = '' }: ModelSelectorProps) => {
const [open, setOpen] = useState(false);
const filtered = onlyAvailable ? providers.filter(p => p.hasKey) : providers;
if (variant === 'cards') {
return (
<div className={`space-y-3 ${className}`}>
{filtered.map(provider => (
<div key={provider.id}>
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider">{provider.name}</span>
{!provider.hasKey && provider.requiresKey && (
<span className="flex items-center gap-0.5 text-[11px] text-amber-400">
<AlertTriangle className="w-2.5 h-2.5" /> No key
</span>
)}
{provider.badge && <span className="text-[11px] text-honey/70">({provider.badge})</span>}
</div>
<div className="flex flex-wrap gap-1.5">
{provider.models.map(m => (
<button key={m.id} onClick={() => onChange(m.id)}
disabled={!provider.hasKey && provider.requiresKey}
className={`px-2.5 py-1 rounded-lg text-[11px] font-display transition-colors ${
value === m.id
? 'bg-primary text-primary-foreground'
: provider.hasKey || !provider.requiresKey
? 'bg-secondary/50 text-foreground hover:bg-secondary'
: 'bg-secondary/20 text-muted-foreground/50 cursor-not-allowed'
}`}>
{m.name}
<span className={`ml-1 ${COST_COLORS[m.cost] ?? ''}`}>{m.cost}</span>
<span className="ml-0.5"><SpeedGlyph speed={m.speed} /></span>
</button>
))}
{provider.models.length === 0 && !provider.requiresKey && (
<span className="text-[11px] text-muted-foreground">Configure in Ollama</span>
)}
{provider.models.length === 0 && provider.requiresKey && provider.hasKey && (
<span className="text-[11px] text-muted-foreground">
{provider.modelsSource === 'unavailable'
? 'Provider catalog unavailable — refresh providers'
: provider.modelsSource === 'stale-provider-api'
? 'Last-known provider catalog unavailable'
: 'No models returned by provider'}
</span>
)}
</div>
</div>
))}
</div>
);
}
// Dropdown variant
return (
<div className={`relative ${className}`}>
<button onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between bg-muted/50 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground hover:border-primary/50 transition-colors">
<span className="truncate">
{/* W2C: the closed button showed the raw id — format it to the
friendly catalog name (matches the list rows below). */}
{value ? formatModelLabel(value, filtered) : 'Select model...'}
{value && (() => {
const p = filtered.find(prov => prov.models.some(m => m.id === value));
if (p && !p.hasKey && p.requiresKey) return <AlertTriangle className="w-3 h-3 text-amber-400 inline ml-1.5" />;
return null;
})()}
</span>
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-card border border-border rounded-xl shadow-lg max-h-72 overflow-auto">
{filtered.map(provider => (
<div key={provider.id}>
<div className="px-3 py-1.5 bg-muted/30 flex items-center gap-1.5">
<span className="text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider">{provider.name}</span>
{!provider.hasKey && provider.requiresKey && (
<span className="flex items-center gap-0.5 text-[11px] text-amber-400">
<Key className="w-2.5 h-2.5" /> No key
</span>
)}
{provider.hasKey && <span className="text-[11px] text-emerald-400"></span>}
{provider.badge && <span className="text-[11px] text-honey/60">{provider.badge}</span>}
</div>
{provider.models.map(m => (
<button key={m.id}
onClick={() => { onChange(m.id); setOpen(false); }}
disabled={!provider.hasKey && provider.requiresKey}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center justify-between ${
value === m.id
? 'bg-primary/10 text-honey'
: provider.hasKey || !provider.requiresKey
? 'text-foreground hover:bg-muted/50'
: 'text-muted-foreground/40 cursor-not-allowed'
}`}>
<span>{m.name}</span>
<span className="flex items-center gap-1.5 text-[11px]">
<span className={COST_COLORS[m.cost] ?? ''}>{m.cost}</span>
<span><SpeedGlyph speed={m.speed} /></span>
</span>
</button>
))}
{provider.models.length === 0 && (
<div className="px-3 py-1.5 text-[11px] text-muted-foreground">
{provider.requiresKey && provider.hasKey
? provider.modelsSource === 'unavailable'
? 'Provider catalog unavailable — refresh providers'
: provider.modelsSource === 'stale-provider-api'
? 'Last-known provider catalog unavailable'
: 'No models returned by provider'
: 'No models — configure locally'}
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default ModelSelector;

View File

@@ -0,0 +1,270 @@
/**
* Lane RT — route-transition system (path-to-9 Pillar 1.1).
*
* Covers the acceptance contract:
* - routeGroupKey keys on the TOP segment only (a workspace sub-tab change is
* the SAME group — no whole-surface crossfade).
* - Interruptibility: two navigations in quick succession → the FINAL route
* wins and is focused, no lock (popLayout, never "wait").
* - Focus + AT ships INSIDE the component: focus moves to the destination
* heading, the exiting tree is inert + aria-hidden, the route is announced
* via a polite live region.
* - Reduced-motion degrades to an instant swap (no opacity animation) while
* focus + announce still fire.
* - The feature flag OFF renders the bare outlet (regression escape hatch).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
// framer-motion caches the prefers-reduced-motion media query at module scope
// (a singleton set on the first useReducedMotion call), so a per-test matchMedia
// swap can't flip it. Override just that hook via a mutable holder; motion +
// AnimatePresence stay real.
const reduceHolder = vi.hoisted(() => ({ value: false }));
vi.mock('framer-motion', async (importOriginal) => {
const actual = await importOriginal<typeof import('framer-motion')>();
return { ...actual, useReducedMotion: () => reduceHolder.value };
});
import RouteTransition from './RouteTransition';
import {
routeGroupKey,
routeAnnouncement,
routeTransitionEnabled,
ROUTE_TRANSITION_FLAG_KEY,
} from '@/lib/motion/route-transition';
// ── Pure helpers ─────────────────────────────────────────────────────────────
describe('routeGroupKey — keys on the top segment only', () => {
it("'/' (transient index) maps to the home group", () => {
expect(routeGroupKey('/')).toBe('home');
});
it('derives the key from the FIRST path segment', () => {
expect(routeGroupKey('/memory')).toBe('memory');
expect(routeGroupKey('/memory/personal')).toBe('memory');
expect(routeGroupKey('/settings/vault')).toBe('settings');
});
it('a workspace sub-tab change is the SAME group (no whole-surface crossfade)', () => {
const chat = routeGroupKey('/workspaces/abc/chat');
const overview = routeGroupKey('/workspaces/abc/overview');
const other = routeGroupKey('/workspaces/xyz');
expect(chat).toBe('workspaces');
expect(overview).toBe('workspaces');
expect(other).toBe('workspaces');
});
it('distinct top-level surfaces are distinct groups', () => {
expect(routeGroupKey('/home')).not.toBe(routeGroupKey('/memory'));
expect(routeGroupKey('/agents')).not.toBe(routeGroupKey('/marketplace'));
});
});
describe('routeAnnouncement — the polite-live-region label', () => {
it('labels known surfaces', () => {
expect(routeAnnouncement('/memory')).toBe('Memory');
expect(routeAnnouncement('/waggle-dance')).toBe('Agent swarm');
expect(routeAnnouncement('/workspaces/abc/chat')).toBe('Workspaces');
});
it('never returns empty for an unknown segment (title-cased fallback)', () => {
expect(routeAnnouncement('/some-unknown-surface')).toBe('Some Unknown Surface');
});
});
describe('routeTransitionEnabled — the kill switch', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults ON', () => {
expect(routeTransitionEnabled()).toBe(true);
});
it('is disabled by the localStorage kill switch', () => {
localStorage.setItem(ROUTE_TRANSITION_FLAG_KEY, 'off');
expect(routeTransitionEnabled()).toBe(false);
});
it('an explicit "on" keeps it enabled', () => {
localStorage.setItem(ROUTE_TRANSITION_FLAG_KEY, 'on');
expect(routeTransitionEnabled()).toBe(true);
});
});
// ── Component ────────────────────────────────────────────────────────────────
function Surface({ id, label }: { id: string; label: string }) {
return (
<div data-testid={`surface-${id}`}>
<h1>{label}</h1>
</div>
);
}
/** A surface with NO <h1> (like Agents/Settings/most of components/os/apps) —
* exercises the broadened focus selector + the named-region fallback. */
function SurfaceNoH1({ id, label }: { id: string; label: string }) {
return (
<div data-testid={`surface-${id}`}>
<h2>{label} section</h2>
<p>body</p>
</div>
);
}
/** Renders navigation controls + the RouteTransition under one layout route. */
function Layout() {
const navigate = useNavigate();
return (
<div>
<button onClick={() => navigate('/memory')}>go-memory</button>
<button onClick={() => navigate('/agents')}>go-agents</button>
<button
onClick={() => {
navigate('/memory');
navigate('/agents');
}}
>
go-double
</button>
<button onClick={() => navigate('/workspaces/a/chat')}>go-ws-chat</button>
<button onClick={() => navigate('/workspaces/a/overview')}>go-ws-overview</button>
<button onClick={() => navigate('/settings')}>go-settings</button>
<button onClick={() => navigate('/home')}>go-home</button>
<RouteTransition />
</div>
);
}
function Harness({ initial = '/home' }: { initial?: string }) {
return (
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="home" element={<Surface id="home" label="Home" />} />
<Route path="memory" element={<Surface id="memory" label="Memory" />} />
<Route path="agents" element={<Surface id="agents" label="Agents" />} />
<Route
path="workspaces/:id/:tab?"
element={<Surface id="ws" label="Workspace" />}
/>
<Route path="settings" element={<SurfaceNoH1 id="settings" label="Settings" />} />
</Route>
</Routes>
</MemoryRouter>
);
}
describe('RouteTransition — component', () => {
beforeEach(() => {
localStorage.clear();
reduceHolder.value = false;
});
afterEach(() => {
cleanup();
reduceHolder.value = false;
});
it('wraps the outlet in the crossfade root and announces politely', () => {
render(<Harness />);
expect(screen.getByTestId('route-transition')).toBeInTheDocument();
const announcer = screen.getByTestId('route-announcer');
expect(announcer).toHaveAttribute('aria-live', 'polite');
// Landing surface is present but NOT announced (first commit is not a nav).
expect(screen.getByTestId('surface-home')).toBeInTheDocument();
expect(announcer).toHaveTextContent('');
});
it('moves focus to the destination heading and announces the route on nav', () => {
render(<Harness />);
fireEvent.click(screen.getByText('go-memory'));
expect(screen.getByTestId('surface-memory')).toBeInTheDocument();
const heading = screen.getByRole('heading', { name: 'Memory' });
expect(document.activeElement).toBe(heading);
expect(screen.getByTestId('route-announcer')).toHaveTextContent('Memory');
});
it('sets the exiting panel inert + aria-hidden so focus cannot land in it', () => {
render(<Harness />);
fireEvent.click(screen.getByText('go-memory'));
const panels = document.querySelectorAll('[data-route-group]');
// The destination panel is live; any other (exiting) panel is inert.
const exiting = Array.from(panels).filter(
(p) => (p as HTMLElement).dataset.routeGroup !== 'memory',
);
for (const p of exiting) {
expect(p).toHaveAttribute('inert');
expect(p).toHaveAttribute('aria-hidden', 'true');
}
});
it('interruptibility: two navigations in quick succession → the final route wins, no lock', () => {
render(<Harness />);
act(() => {
fireEvent.click(screen.getByText('go-double')); // navigate(/memory) then (/agents)
});
// The FINAL route mounted immediately (popLayout, not "wait") and is focused.
expect(screen.getByTestId('surface-agents')).toBeInTheDocument();
const heading = screen.getByRole('heading', { name: 'Agents' });
expect(document.activeElement).toBe(heading);
});
it('no-<h1> surface: focus lands on a heading (broadened selector), not a generic dump (V3 fix)', () => {
render(<Harness />);
fireEvent.click(screen.getByText('go-settings'));
// The h2 is now a valid focus target (selector broadened from h1-only).
const heading = screen.getByRole('heading', { name: 'Settings section' });
expect(document.activeElement).toBe(heading);
expect(screen.getByTestId('route-announcer')).toHaveTextContent('Settings');
});
it('re-entry A→B→A within the exit window leaves the destination interactive, not stale-inert (V1 fix)', () => {
render(<Harness />);
act(() => { fireEvent.click(screen.getByText('go-memory')); });
act(() => { fireEvent.click(screen.getByText('go-home')); }); // back to home while memory (or home's prior) may still be exiting
const homePanel = document.querySelector('[data-route-group="home"]') as HTMLElement;
expect(homePanel).not.toBeNull();
// The destination must NOT retain a stale inert/aria-hidden from a prior exit.
expect(homePanel.hasAttribute('inert')).toBe(false);
expect(homePanel.getAttribute('aria-hidden')).not.toBe('true');
// Focus is inside the destination, never dropped to <body>.
expect(document.activeElement).not.toBe(document.body);
expect(homePanel.contains(document.activeElement)).toBe(true);
});
it('a workspace sub-tab change updates the SAME panel in place (no new crossfade)', () => {
render(<Harness />);
fireEvent.click(screen.getByText('go-ws-chat'));
expect(screen.getByTestId('route-announcer')).toHaveTextContent('Workspaces');
// Capture the workspaces panel node. A sub-tab change is the SAME group key,
// so the panel must be reconciled IN PLACE (same DOM node) — no exit/enter —
// rather than crossfaded. (Node identity is robust vs. framer's lingering
// exit panel from the earlier home→workspaces transition.)
const wsPanelBefore = document.querySelector('[data-route-group="workspaces"]');
expect(wsPanelBefore).not.toBeNull();
fireEvent.click(screen.getByText('go-ws-overview'));
const wsPanelAfter = document.querySelector('[data-route-group="workspaces"]');
expect(wsPanelAfter).toBe(wsPanelBefore);
});
it('reduced-motion → instant swap (no opacity anim) while focus + announce still fire', () => {
reduceHolder.value = true;
render(<Harness />);
fireEvent.click(screen.getByText('go-memory'));
const panel = document.querySelector('[data-route-group="memory"]') as HTMLElement;
expect(panel).toHaveAttribute('data-reduced', 'true');
// Focus + announce are NOT gated by reduced motion.
expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Memory' }));
expect(screen.getByTestId('route-announcer')).toHaveTextContent('Memory');
});
it('feature flag OFF → renders the bare outlet (no crossfade root, no announcer)', () => {
localStorage.setItem(ROUTE_TRANSITION_FLAG_KEY, 'off');
render(<Harness />);
expect(screen.queryByTestId('route-transition')).not.toBeInTheDocument();
expect(screen.queryByTestId('route-announcer')).not.toBeInTheDocument();
// The surface still renders (bare outlet).
expect(screen.getByTestId('surface-home')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,139 @@
/**
* RouteTransition — the DEFAULT motion tier for top-level route changes
* (path-to-9 Pillar 1.1 · Lane RT). Wraps the AppShell `<Outlet/>` in a
* fade-through crossfade with PERSISTENT chrome (the sidebar + StatusBar live
* OUTSIDE this subtree in AppShell, so they never fade). NOT a global router
* rewrite — the router is untouched; this only reshapes what renders into the
* shell's single canvas.
*
* Design contract:
* 1. DEFAULT crossfade, keyed by ROUTE GROUP (top path segment — see
* routeGroupKey), so a workspace sub-tab change never crossfades the whole
* surface; only a top-level surface change (home→memory→settings…) does.
* Enter/exit = opacity fade (DUR.base + EASE_OUT).
* 2. Interruptibility + input-primacy (ACCEPTANCE): `mode="popLayout"` (never
* "wait") so an exit NEVER blocks the next enter — a route change
* mid-transition redirects immediately; the final route always wins.
* 3. Focus + assistive-tech, shipped INSIDE this component: on a group change
* the exiting panel is set `inert`+`aria-hidden` (focus / the SR virtual
* cursor can never land in it), focus moves to the destination surface's
* primary heading (or the panel landmark), and the route is announced via a
* polite live region.
* 4. Reduced-motion (REDUCED.routeTransition): no opacity animation — an
* instant swap; focus + announce still fire.
* 5. Feature-flagged (routeTransitionEnabled) — OFF renders the bare outlet,
* the exact pre-Lane-RT behaviour, so a regression is one flag flip.
*
* ChatHost is deliberately NOT wrapped (it is a sibling in AppShell) — it keeps
* in-flight SSE alive across route changes; wrapping it here would remount it.
*/
import { useLayoutEffect, useRef, useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { useLocation, useOutlet } from 'react-router-dom';
import { DUR, EASE_OUT } from '@/lib/motion/tokens';
import {
routeAnnouncement,
routeGroupKey,
routeTransitionEnabled,
} from '@/lib/motion/route-transition';
export default function RouteTransition() {
const location = useLocation();
const outlet = useOutlet();
const reduce = useReducedMotion();
// Read the kill switch once per mount — it is a regression escape hatch, not a
// live toggle (a flip takes effect on the next app load).
const [enabled] = useState(routeTransitionEnabled);
const groupKey = routeGroupKey(location.pathname);
const rootRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState('');
// The landing surface is not a navigation — skip focus-move + announce on the
// first commit so boot never steals focus or announces the entry surface.
const firstRun = useRef(true);
// Focus + AT (item 3). Runs on a route-GROUP change only, so a workspace
// sub-tab change (same group) can never steal focus. useLayoutEffect → the
// focus move lands before paint (no focus-ring flash on the exiting tree).
useLayoutEffect(() => {
if (!enabled) return;
if (firstRun.current) {
firstRun.current = false;
return;
}
const root = rootRef.current;
if (!root) return;
const panels = Array.from(root.querySelectorAll<HTMLElement>('[data-route-group]'));
const dest = panels.find((p) => p.dataset.routeGroup === groupKey) ?? null;
// Every panel that is NOT the destination is exiting — make it unreachable to
// focus and to the SR virtual cursor for the remainder of its exit.
for (const p of panels) {
if (p !== dest) {
p.setAttribute('inert', '');
p.setAttribute('aria-hidden', 'true');
}
}
if (dest) {
// V1 catch: on an A→B→A re-entry within A's exit window, popLayout reuses
// A's still-exiting node as the destination — which we already marked
// inert+aria-hidden while it was exiting. Clear those FIRST, or focus()
// silently no-ops (inert can't receive focus) and the landed surface stays
// dead to keyboard+mouse and hidden from SR.
dest.removeAttribute('inert');
dest.removeAttribute('aria-hidden');
// V3 catch: most surfaces have no <h1> (only 8 of 59). Broaden the target
// to any heading/landmark; when none exists, give the fallback wrapper an
// accessible name so a SR user lands on a NAMED region, not a generic dump.
const heading = dest.querySelector<HTMLElement>('h1, h2, [role="heading"], [data-route-heading]');
const target = heading ?? dest;
if (target === dest) {
dest.setAttribute('role', 'region');
dest.setAttribute('aria-label', routeAnnouncement(location.pathname));
}
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
}
setAnnouncement(routeAnnouncement(location.pathname));
}, [groupKey, enabled, location.pathname]);
// Kill switch: the bare outlet, byte-for-byte the pre-Lane-RT behaviour.
if (!enabled) return <>{outlet}</>;
return (
<div
ref={rootRef}
data-testid="route-transition"
className="relative h-full w-full overflow-hidden"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={groupKey}
data-route-group={groupKey}
data-reduced={reduce ? 'true' : 'false'}
className="absolute inset-0 h-full w-full"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: reduce ? 0 : DUR.base, ease: EASE_OUT }}
>
{outlet}
</motion.div>
</AnimatePresence>
{/* Polite route announce (item 3). Stable node outside AnimatePresence so
the swap only mutates its text — SR reads the destination label. */}
<div
aria-live="polite"
role="status"
className="sr-only"
data-testid="route-announcer"
>
{announcement}
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import type { ElementType } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, Plus, Search } from "lucide-react";
import { cmdKLabel } from "@/lib/platform";
import { HintTooltip } from "@/components/ui/hint-tooltip";
import { TooltipProvider } from "@/components/ui/tooltip";
/**
* Warm-Hive calm spine (design ref: design-files/screens/ia.html).
*
* The always-visible nav collapses to FIVE everyday places (Home · Chat · Memory ·
* Agents & tasks · Library); everything else lives one keystroke away in ⌘K. A
* power-tier "Pinned · power tools" group floats the tools a power user lives in.
* Purely presentational — AppShell resolves routes/badges/tier and passes them in,
* so the spine is trivial to unit-test.
*/
export interface SidebarNavItem {
key: string;
label: string;
icon: ElementType;
/** Resolved navigation target. */
to: string;
/** Route prefixes that mark this item active (exact or `${prefix}/…`). */
match: string[];
/** Optional predicate override for active state (used when a static prefix
* can't express the route, e.g. Chat = /workspaces/:id/chat). */
activeWhen?: (pathname: string) => boolean;
/** Optional attention count; only rendered when > 0. */
badge?: number;
/** Optional click override — e.g. open the workspace switcher when there is
* no real workspace to chat in (avoids a dead nav to `to`). Falls back to
* navigating to `to` when absent. */
onClick?: () => void;
}
interface SidebarProps {
workspaceName: string | null;
spine: SidebarNavItem[];
/** Pro "Pinned · power tools" group (empty for non-power tiers). */
pinned?: SidebarNavItem[];
onOpenWorkspaceSwitcher: () => void;
onOpenCommand: () => void;
onSpawnAgent: () => void;
userName: string | null;
tierLabel: string;
}
function initialOf(name: string | null, fallback: string): string {
const c = name?.trim()?.[0];
return (c ?? fallback).toUpperCase();
}
const Sidebar = ({
workspaceName,
spine,
pinned = [],
onOpenWorkspaceSwitcher,
onOpenCommand,
onSpawnAgent,
userName,
tierLabel,
}: SidebarProps) => {
const navigate = useNavigate();
const { pathname } = useLocation();
const isActive = (item: SidebarNavItem): boolean =>
item.activeWhen
? item.activeWhen(pathname)
: item.match.some((p) => pathname === p || pathname.startsWith(`${p}/`));
const renderNavItem = (item: SidebarNavItem) => {
const active = isActive(item);
const Icon = item.icon;
return (
// Below lg the sidebar collapses to an icon rail — the hover tooltip is the
// only way to read the label there. It's redundant (but harmless) at ≥lg.
<HintTooltip key={item.key} content={item.label} side="right">
<button
data-testid={`nav-${item.key}`}
aria-label={item.label}
aria-current={active ? "page" : undefined}
onClick={() => (item.onClick ? item.onClick() : navigate(item.to))}
className={`relative flex items-center justify-center gap-3 rounded-[10px] px-2.5 py-2.5 text-left transition-colors lg:justify-start ${
active
? "bg-[var(--honey-wash)] text-[var(--text)]"
: "text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
}`}
>
{active && (
<span
aria-hidden
className="absolute -left-3 top-1/2 h-[18px] w-[3px] -translate-y-1/2 rounded bg-[var(--honey)]"
/>
)}
<Icon
className={`h-[19px] w-[19px] shrink-0 ${active ? "text-[var(--honey-text)]" : ""}`}
strokeWidth={1.7}
/>
<span className="hidden flex-1 text-sm font-medium lg:inline">{item.label}</span>
{!!item.badge && item.badge > 0 && (
<span className="hidden rounded-full bg-[var(--honey-wash)] px-[7px] py-0.5 font-mono text-[10.5px] text-[var(--attention)] lg:inline">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</button>
</HintTooltip>
);
};
// Wave V Lane F (a11y): the section labels sit on --bg-2, which is one step
// darker than --bg in light — where --text-dim measured 4.47:1 (sub-AA at
// 9.5px). --text-muted clears it on --bg-2 in both themes (4.77:1 light /
// 6.31:1 dark) while staying quieter than body text. (--text-dim stays tuned
// for its --bg surfaces elsewhere; fixing it globally would over-lighten those.)
// Wave X Lane C: 9.5px/0.14em uppercase in --text-muted read as garbled noise
// (video judge). Bumped to 10.5px and eased tracking to 0.10em so the zone
// eyebrows ("PINNED · POWER TOOLS" / "GENERAL") stay legible at 1×.
const zoneLabel = "hidden lg:flex items-center gap-2 px-2.5 pt-3.5 pb-1.5 font-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-muted)]";
return (
<TooltipProvider>
<nav
role="navigation"
aria-label="Primary"
className="waggle-sidebar relative z-10 flex w-16 lg:w-[248px] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--line-soft)] bg-[var(--bg-2)] px-2 lg:px-3 py-3.5"
>
{/* Workspace switcher pill */}
<HintTooltip content={workspaceName ?? "Workspace"} side="right">
<button
data-testid="sidebar-workspace"
aria-label="Switch workspace"
onClick={onOpenWorkspaceSwitcher}
className="mb-2.5 flex items-center justify-center gap-2.5 rounded-[11px] border border-[var(--line-soft)] bg-card px-2.5 py-2 text-left transition-colors hover:border-[var(--honey-line)] lg:justify-start"
>
<span className="hex grid h-8 w-7 shrink-0 place-items-center bg-[linear-gradient(150deg,var(--honey-bright),var(--honey-deep))] text-[12px] font-extrabold text-[#1a1407]">
{initialOf(workspaceName, "W")}
</span>
<span className="hidden min-w-0 flex-1 lg:block">
<span className="block truncate text-[13px] font-semibold leading-tight">
{workspaceName ?? "Workspace"}
</span>
{/* Lane F2: --text-dim measured 4.38:1 on --surface (bg-card) in dark
— sub-AA at 11px. --text-tertiary is the AA-on-every-surface tier
(5.98:1 dark / 5.68:1 light) and stays quieter than the name above. */}
<span className="text-[11px] text-[var(--text-tertiary)]">workspace</span>
</span>
<ChevronDown className="hidden h-4 w-4 shrink-0 text-[var(--text-dim)] lg:block" />
</button>
</HintTooltip>
{/* Five-place calm spine */}
{spine.map(renderNavItem)}
{/* Pro "Pinned · power tools" group */}
{pinned.length > 0 && (
<>
<div className={zoneLabel}>Pinned · power tools</div>
{pinned.map(renderNavItem)}
</>
)}
{/* General → ⌘K */}
<div className={zoneLabel}>General</div>
<HintTooltip content={`Search & commands (${cmdKLabel})`} side="right">
<button
data-testid="sidebar-command"
aria-label="Search and commands"
onClick={onOpenCommand}
className="flex items-center justify-center gap-3 rounded-[10px] border border-dashed border-[var(--line-strong)] px-2.5 py-2.5 text-left text-[var(--text-muted)] transition-colors hover:border-[var(--honey-line)] hover:bg-[var(--honey-wash)] hover:text-[var(--honey-text)] lg:justify-start"
>
<Search className="h-[18px] w-[18px] shrink-0" strokeWidth={1.7} />
<span className="hidden flex-1 text-[13px] font-semibold lg:inline">Search &amp; commands</span>
<kbd className="hidden rounded-md border border-[var(--line-strong)] bg-card px-[7px] py-0.5 font-mono text-[11px] text-[var(--text-2)] lg:inline">
{cmdKLabel}
</kbd>
</button>
</HintTooltip>
{/* New agent — primary spawn affordance (also reachable from ⌘K) */}
<HintTooltip content="New Agent" side="right">
<button
data-testid="nav-spawn-agent"
aria-label="New Agent"
onClick={onSpawnAgent}
className="mt-1.5 flex items-center justify-center gap-2.5 rounded-[10px] border border-[var(--line)] bg-card px-2.5 py-2 text-left text-[var(--text-2)] transition-colors hover:border-[var(--honey-line)] hover:text-[var(--honey-text)] lg:justify-start"
>
<Plus className="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
<span className="hidden flex-1 text-[13px] font-semibold lg:inline">New Agent</span>
</button>
</HintTooltip>
<div className="flex-1" />
{/* User row → Settings */}
<HintTooltip content={userName ?? "Account"} side="right">
<button
data-testid="sidebar-user"
aria-label="Account and settings"
onClick={() => navigate("/settings")}
className="mt-1.5 flex items-center justify-center gap-2.5 border-t border-[var(--line-soft)] px-2.5 py-2 text-left lg:justify-start"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[var(--honey)] text-[11px] font-bold text-[#1a1407]">
{initialOf(userName, "W")}
</span>
<span className="hidden min-w-0 flex-1 lg:block">
<span className="block truncate text-[13px] font-semibold">
{userName ?? "Account"}
</span>
{/* Lane F2: --text-dim measured 4.47:1 on --bg-2 (nav) in light — sub-AA.
--text-tertiary clears it (4.77:1 light / 6.31:1 dark). */}
<span className="font-mono text-[10.5px] text-[var(--text-tertiary)]">{tierLabel}</span>
</span>
</button>
</HintTooltip>
</nav>
</TooltipProvider>
);
};
export default Sidebar;

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
const mocks = vi.hoisted(() => ({
adapter: {
getMemoryStats: vi.fn().mockResolvedValue({ total: { frames: 0 } }),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/hooks/useProviders', () => ({
useProviders: () => ({ providers: [] }),
}));
import StatusBar from './StatusBar';
describe('StatusBar', () => {
it('renders the status logo with stable intrinsic dimensions', () => {
render(
<MemoryRouter>
<TooltipProvider>
<StatusBar />
</TooltipProvider>
</MemoryRouter>,
);
const logo = screen.getByAltText('Waggle');
expect(logo).toHaveAttribute('width', '32');
expect(logo).toHaveAttribute('height', '32');
});
});

View File

@@ -0,0 +1,265 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { WifiOff, Search, Bell, Brain } from "lucide-react";
import waggleLogoDark from "@/assets/waggle-logo.jpeg";
import waggleLogoLight from "@/assets/waggle-logo.png";
import { useIsLightTheme } from "@/hooks/useIsLightTheme";
import { HintTooltip } from "@/components/ui/hint-tooltip";
import { useDeveloperMode } from "@/hooks/useDeveloperMode";
import { useProviders } from "@/hooks/useProviders";
import { formatModelLabel } from "@/lib/model-label";
import { adapter } from "@/lib/adapter";
import { DATE_LOCALE } from "@/lib/date-locale";
import { WaggleSettle, claimFullSignature } from "@/components/os/warm";
interface StatusBarProps {
workspaceName?: string;
/**
* P39 → P1a: the active surface's breadcrumb label. Derived by AppShell
* from the matched route's nav title (replaces the old status-bar focus
* builder, which died with the window manager — conversion plan §3.1).
*/
focusedWindowLabel?: string | null;
model?: string;
tokensUsed?: number;
costUsd?: number;
offline?: boolean;
unreadNotifications?: number;
trialDaysRemaining?: number;
trialExpired?: boolean;
onSearchClick?: () => void;
onNotificationClick?: () => void;
}
const StatusBar = ({ workspaceName, focusedWindowLabel, model, tokensUsed, costUsd, offline, unreadNotifications = 0, trialDaysRemaining: trialDays, trialExpired, onSearchClick, onNotificationClick }: StatusBarProps) => {
const [time, setTime] = useState(new Date());
const navigate = useNavigate();
const isLight = useIsLightTheme();
const waggleLogo = isLight ? waggleLogoLight : waggleLogoDark;
// M-20 / UX-5: token + cost are developer-facing signal. Hidden by
// default; Settings → Advanced → Developer mode flips them on.
const [developerMode] = useDeveloperMode();
// W2C: format the raw model id into a friendly display name via the shared
// formatter (catalog lookup + heuristic). The chip means "model this
// workspace's chat will use"; the tooltip carries the raw id + where to change it.
const { providers } = useProviders();
const modelLabel = formatModelLabel(model, providers);
// F2 from the 2026-05-28 addictiveness audit — surface accumulated
// memory count as a visible "trophy" so users see their investment
// compounding (rubric dim 8). Hidden when the count is zero (a
// brand-new user is better served by the LoginBriefing demo hook).
const [memoryFrameCount, setMemoryFrameCount] = useState<number | null>(null);
// Signature motion: when the REAL count increases between polls, a small
// “+N ⬡” particle folds into the hive (the brain chip) and fades. Honest by
// construction — it only ever fires on an actual frame-count increase.
const [foldDelta, setFoldDelta] = useState<number | null>(null);
// Lane WS: the commissioned waggle-settle plays over the memory chip on the
// FIRST real memory-saved of the session (SIGNATURE.full gate + cooldown) —
// the prototype's single wired moment. Reduced-motion degrades inside it.
const [settlePlay, setSettlePlay] = useState(false);
useEffect(() => {
let cancelled = false;
let foldTimer: ReturnType<typeof setTimeout> | undefined;
const load = () => {
adapter.getMemoryStats()
.then(stats => {
if (cancelled) return;
// adapter.getMemoryStats normalises to { personal, workspace,
// total } with `frames` on each bucket — same shape that powers
// the LoginBriefing brag line.
const n = stats?.total?.frames ?? 0;
setMemoryFrameCount(prev => {
if (prev !== null && n > prev) {
setFoldDelta(n - prev);
if (foldTimer) clearTimeout(foldTimer);
foldTimer = setTimeout(() => { if (!cancelled) setFoldDelta(null); }, 2000);
// Signature flourish — gated to first-of-session + cooldown, so it
// fires at most once per session (claim is idempotent under a
// double-invoked updater in StrictMode).
if (claimFullSignature('memory-saved-first-of-session')) setSettlePlay(true);
}
return n > 0 ? n : null;
});
})
.catch(() => { /* silent — leave count hidden */ });
};
load();
// Refresh every 60s so the trophy ticks up during active use.
const id = setInterval(load, 60_000);
return () => { cancelled = true; clearInterval(id); if (foldTimer) clearTimeout(foldTimer); };
}, []);
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval);
}, []);
const formatTime = (d: Date) =>
d.toLocaleTimeString(DATE_LOCALE, { hour: "2-digit", minute: "2-digit", hour12: false });
const formatDate = (d: Date) =>
d.toLocaleDateString(DATE_LOCALE, { weekday: "short", month: "short", day: "numeric" });
return (
<header
aria-label="Application status"
className="waggle-statusbar fixed top-0 left-0 right-0 z-50 h-8 glass-strong flex items-center justify-between px-4 select-none"
>
<div className="flex items-center gap-3 min-w-0">
{/* R11 Lane D: the full logo (mark + WAGGLE wordmark) crammed into 16px
read as a muddy dark tile in light. Clip to just the bee mark — a 200%
image nudged up/left so the wordmark falls outside the 16px window —
so it reads as an orange mark on the asset's own bg in both themes,
never a dark square. A faint ring keeps the cream tile crisp on ivory. */}
<span className="w-4 h-4 rounded-[4px] overflow-hidden shrink-0 inline-flex ring-1 ring-border/40">
<img
src={waggleLogo}
alt="Waggle"
width={32}
height={32}
className="w-[200%] h-[200%] max-w-none object-cover -translate-x-1/4 -translate-y-[14%]"
/>
</span>
<span className="text-xs font-display font-semibold text-foreground whitespace-nowrap shrink-0">Waggle AI</span>
{/* L-02: hide workspace + model below md (~768px) so the logo
+ "Waggle AI" stay visible on narrow windows. */}
{workspaceName && (
<>
<span className="text-muted-foreground text-[11px] hidden lg:inline">·</span>
<span className="text-[11px] text-muted-foreground hidden lg:inline">{workspaceName}</span>
</>
)}
{focusedWindowLabel && (
<>
<span className="text-muted-foreground text-[11px] hidden lg:inline">·</span>
<HintTooltip content={focusedWindowLabel}>
<span
className="text-[11px] text-foreground/80 font-display hidden lg:inline truncate max-w-[240px]"
data-testid="statusbar-focused-window"
tabIndex={0}
>
{focusedWindowLabel}
</span>
</HintTooltip>
</>
)}
{model && (
<>
<span className="text-muted-foreground text-[11px] hidden lg:inline">·</span>
{/* R9 kw judge: "Default: Haiku" beside a thread running Opus read as
two contradictory truths. The label now states its SCOPE — this
chip is the new-chat default; an open thread's model lives in the
chat header. Scoping, not fake agreement.
R11 kw: "New chats:" was clever-but-oblique — "Default model:" is
self-evident; the tooltip still disambiguates the open thread. */}
<HintTooltip content={`${modelLabel} (${model}) — default model for new chats in this workspace. An open chat may use its own model (shown in the chat header); the global default lives in Settings → Models.`}>
<span
className="text-[11px] text-honey font-display hidden lg:inline cursor-help"
data-testid="statusbar-model"
tabIndex={0}
>
Default model: {modelLabel}
</span>
</HintTooltip>
</>
)}
{memoryFrameCount !== null && (
<>
<span className="text-muted-foreground text-[11px] hidden lg:inline">·</span>
<HintTooltip content={`${memoryFrameCount.toLocaleString()} memory frames across all minds (personal + every workspace). This grows every time you chat — it's why Waggle gets better the more you use it.`}>
<span
className={`relative text-[11px] text-honey font-display hidden lg:inline-flex items-center gap-1 cursor-help rounded-full px-1 ${foldDelta !== null ? 'honey-pulse' : ''}`}
data-testid="statusbar-memory-count"
aria-label={`${memoryFrameCount.toLocaleString()} memory frames across all minds`}
>
<Brain className="w-3 h-3" aria-hidden="true" />
{memoryFrameCount.toLocaleString()} memories
{/* R10 Lane D (kw #1): the status bar counts ALL minds while the
Memory page counts the personal mind — two honest numbers that
read as a contradiction unscoped. Name the scope in the chip.
Wave V Lane F (a11y): the scope suffix carried the honey text at
opacity-60 (≈2.4:1 in light — sub-AA). Give it the muted-text
token instead: it still de-emphasises vs the honey count but
clears AA (≈5.6:1 light / 6.1:1 dark) in both themes. */}
<span className="text-[var(--text-muted)]"> · all minds</span>
{foldDelta !== null && (
<span aria-hidden className="memory-fold absolute -top-3 right-0 text-[10px] font-semibold text-honey whitespace-nowrap pointer-events-none">
+{foldDelta}
</span>
)}
<WaggleSettle play={settlePlay} onDone={() => setSettlePlay(false)} size={20} />
</span>
</HintTooltip>
</>
)}
{developerMode && tokensUsed !== undefined && tokensUsed > 0 && (
<>
<span className="text-muted-foreground text-[11px]">·</span>
<span className="text-[11px] text-muted-foreground" data-testid="statusbar-tokens">{tokensUsed.toLocaleString()} tok</span>
</>
)}
{developerMode && costUsd !== undefined && costUsd > 0 && (
<span className="text-[11px] text-muted-foreground" data-testid="statusbar-cost">${costUsd.toFixed(4)}</span>
)}
</div>
<div className="flex items-center gap-2 lg:gap-4 shrink-0">
{trialDays !== undefined && trialDays > 0 && (
<span className={`text-[10px] font-display font-semibold px-2 py-0.5 rounded-full whitespace-nowrap ${trialDays <= 3 ? 'bg-destructive/20 text-destructive' : 'bg-primary/15 text-honey'}`}>
Trial: {trialDays}d left
</span>
)}
{trialExpired && (
/* R9 Lane D: the amber pill "screamed" on every screen for a benign
steady state (you're on the free Solo plan). Demoted to a quiet
neutral text-chip — still a button that routes to plans. */
<button
type="button"
onClick={() => navigate('/settings?tab=billing')}
className="text-[10px] font-display px-1.5 py-0.5 rounded-md text-muted-foreground whitespace-nowrap transition-colors hover:text-honey"
title="You're on the free Solo plan (your trial ended). See plans."
>
Solo plan
</button>
)}
<HintTooltip content="Search (Ctrl+K)">
<button
onClick={onSearchClick}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-md border border-border/40 bg-secondary/40 text-muted-foreground hover:text-honey hover:border-primary/40 transition-colors"
aria-label="Search"
>
<Search className="w-3 h-3" />
<span className="text-[10px] font-display">Search</span>
<kbd className="text-[10px] px-1 py-0.5 rounded bg-muted border border-border/40 font-mono">Ctrl K</kbd>
</button>
</HintTooltip>
{/* Round-6: an overlapping badge can never sit right on a 14px bell —
it occluded the glyph. Count now renders BESIDE the bell inside the
same click target: unambiguous, nothing covered, nothing clipped. */}
<button onClick={onNotificationClick} className="flex items-center gap-1 text-muted-foreground hover:text-honey transition-colors" aria-label={`Notifications${unreadNotifications > 0 ? ` (${unreadNotifications} unread)` : ''}`}>
<Bell className="w-3.5 h-3.5" />
{unreadNotifications > 0 && (
<span className="min-w-[15px] h-[15px] rounded-full bg-[var(--honey)] text-[10px] leading-none text-[#1a1407] flex items-center justify-center font-bold px-1 whitespace-nowrap">
{unreadNotifications > 9 ? '9+' : unreadNotifications}
</span>
)}
</button>
{offline && (
<div className="relative group">
<button className="flex items-center gap-1 text-destructive" aria-label="Backend offline — messages will be queued">
<WifiOff className="w-3.5 h-3.5" />
<span className="text-[10px] font-display animate-pulse motion-reduce:animate-none">Offline</span>
</button>
<div className="absolute top-full right-0 mt-2 w-48 p-2.5 rounded-xl glass-strong border border-border/50 shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
<p className="text-[11px] font-display font-semibold text-foreground mb-1">Backend Unreachable</p>
<p className="text-[10px] text-muted-foreground">Messages will be queued and sent when the connection is restored.</p>
</div>
</div>
)}
<span className="text-xs text-muted-foreground hidden lg:inline">{formatDate(time)}</span>
<span className="text-xs text-foreground font-medium">{formatTime(time)}</span>
</div>
</header>
);
};
export default StatusBar;

View File

@@ -0,0 +1,266 @@
/**
* WorkspaceActionsMenu — the single management surface for a workspace
* (UX-Northstar 2026-06-13 G1). Kebab trigger → ContextMenu with:
* Rename · Archive/Restore · Export summary · Delete…
*
* Mounted wherever a workspace is shown (Home cards, WorkspaceSwitcher rows,
* Workspace Desktop header). Mutations go through ShellContext so the
* canonical workspace list stays in sync; hosts with their own server-fed
* views (Home briefing) refresh via `onChanged`.
*/
import { useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { MoreHorizontal, Pencil, Archive, ArchiveRestore, Download, Trash2 } from 'lucide-react';
import ContextMenu, { type ContextMenuItem } from './ContextMenu';
import { useShell } from '@/providers/ShellContext';
import { useToast } from '@/hooks/use-toast';
import { adapter } from '@/lib/adapter';
export type WorkspaceAction = 'rename' | 'archive' | 'restore' | 'delete' | 'export';
interface WorkspaceActionsMenuProps {
workspace: { id: string; name: string; status?: 'active' | 'paused' | 'archived' };
/** Host-local refresh (e.g. Home briefing reload, Desktop context reload). */
onChanged?: (action: WorkspaceAction) => void;
/** Extra classes for the kebab trigger button. */
buttonClassName?: string;
}
function downloadMarkdown(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const WorkspaceActionsMenu = ({ workspace, onChanged, buttonClassName }: WorkspaceActionsMenuProps) => {
const { patchWorkspace, deleteWorkspace } = useShell();
const { toast } = useToast();
const triggerRef = useRef<HTMLButtonElement>(null);
const formId = useId();
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState(workspace.name);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
const [memoryCount, setMemoryCount] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const isArchived = workspace.status === 'archived';
const openMenu = (e: React.MouseEvent) => {
e.stopPropagation();
const rect = triggerRef.current?.getBoundingClientRect();
setMenuPos(rect ? { x: rect.left, y: rect.bottom + 4 } : { x: e.clientX, y: e.clientY });
};
const handleRename = async () => {
const name = renameValue.trim();
if (!name || name === workspace.name) { setRenameOpen(false); return; }
setBusy(true);
const ok = await patchWorkspace(workspace.id, { name });
setBusy(false);
setRenameOpen(false);
if (ok) {
toast({ title: `Renamed to "${name}"` });
onChanged?.('rename');
} else {
toast({ title: 'Couldnt rename workspace', description: 'Check your connection and try again.', variant: 'destructive' });
}
};
const handleArchiveToggle = async () => {
const next = isArchived ? 'active' : 'archived';
const ok = await patchWorkspace(workspace.id, { status: next });
if (ok) {
toast({
title: isArchived ? `"${workspace.name}" is back` : `"${workspace.name}" archived`,
description: isArchived
? 'It will show up in your lists again.'
: 'Its memory is kept safe. Restore it anytime from the workspace switcher.',
});
onChanged?.(isArchived ? 'restore' : 'archive');
} else {
toast({ title: `Couldnt ${isArchived ? 'restore' : 'archive'} workspace`, description: 'Check your connection and try again.', variant: 'destructive' });
}
};
const handleExport = async () => {
try {
const blob = await adapter.exportWorkspaceBriefing(workspace.id);
downloadMarkdown(blob, `${workspace.name.replace(/[^\w-]+/g, '-')}-summary.md`);
toast({ title: 'Summary downloaded' });
onChanged?.('export');
} catch {
toast({ title: 'Couldnt export summary', description: 'Check your connection and try again.', variant: 'destructive' });
}
};
const openDeleteDialog = () => {
setDeleteConfirm('');
setMemoryCount(null);
setDeleteOpen(true);
// Best-effort: show what's at stake. The dialog works without it.
adapter.getWorkspaceContext(workspace.id)
.then(ctx => setMemoryCount(ctx.stats?.memoryCount ?? null))
.catch(() => {});
};
const handleDelete = async () => {
setBusy(true);
const ok = await deleteWorkspace(workspace.id);
setBusy(false);
setDeleteOpen(false);
if (ok) {
toast({ title: `"${workspace.name}" deleted` });
onChanged?.('delete');
} else {
toast({ title: 'Couldnt delete workspace', description: 'Nothing was removed. Check your connection and try again.', variant: 'destructive' });
}
};
const items: ContextMenuItem[] = [
{
label: 'Rename',
icon: <Pencil className="w-3.5 h-3.5" />,
onClick: () => { setRenameValue(workspace.name); setRenameOpen(true); },
},
{
label: isArchived ? 'Restore' : 'Archive',
icon: isArchived ? <ArchiveRestore className="w-3.5 h-3.5" /> : <Archive className="w-3.5 h-3.5" />,
onClick: () => { void handleArchiveToggle(); },
},
{
label: 'Export summary',
icon: <Download className="w-3.5 h-3.5" />,
onClick: () => { void handleExport(); },
},
{ label: '', onClick: () => {}, separator: true },
{
label: 'Delete…',
icon: <Trash2 className="w-3.5 h-3.5" />,
danger: true,
onClick: openDeleteDialog,
},
];
const deleteMatches = deleteConfirm.trim() === workspace.name;
const renameInputId = `${formId}-workspace-name`;
const deleteConfirmInputId = `${formId}-workspace-delete-confirmation`;
return (
<>
<button
ref={triggerRef}
onClick={openMenu}
aria-label={`Workspace actions for ${workspace.name}`}
data-testid="workspace-actions-trigger"
// Pillar 2.7 (Lane K): keyboard parity for the hover-revealed kebab.
// `focus-visible:opacity-100` guarantees the kebab surfaces on Tab even
// if a host omits it from buttonClassName (the systemic `:focus-visible`
// ring in index.css then paints the --focus-ring outline); the centered
// 40px ::before lifts the effective hit target from 24px with no visual
// size change.
className={`relative p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors focus-visible:opacity-100 before:absolute before:left-1/2 before:top-1/2 before:h-10 before:w-10 before:-translate-x-1/2 before:-translate-y-1/2 before:content-[''] ${buttonClassName ?? ''}`}
>
<MoreHorizontal className="w-4 h-4" />
</button>
{/* Portal: hosts include transformed ancestors (the switcher modal's
framer-motion scale), which turn position:fixed into position-
relative-to-ancestor — the menu/dialogs must escape to the body
(same fix class as the dock-tray portal, 0de190f). */}
{createPortal(<>
{menuPos && (
// Wave W Lane B (item 2): the menu opens at the kebab's bottom-left, so it
// scales in from its top-left corner (roomier "comfortable" density too).
// Escape-returns-focus is preserved — ContextMenu never steals focus from
// the trigger, so closing lands it back on the kebab.
<ContextMenu items={items} position={menuPos} onClose={() => setMenuPos(null)} origin="top left" />
)}
{renameOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center" onClick={() => setRenameOpen(false)}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className="relative w-full max-w-sm glass-strong rounded-2xl shadow-2xl p-5" onClick={e => e.stopPropagation()}>
<h2 className="text-sm font-display font-semibold text-foreground mb-3">Rename workspace</h2>
<label htmlFor={renameInputId} className="block text-xs text-muted-foreground mb-1">
Workspace name
</label>
<input
id={renameInputId}
name="workspace-name"
autoComplete="off"
autoFocus
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleRename(); if (e.key === 'Escape') setRenameOpen(false); }}
data-testid="workspace-rename-input"
className="w-full px-3 py-2 rounded-xl bg-secondary/30 border border-border text-sm text-foreground focus:outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<div className="flex justify-end gap-2 mt-4">
<button onClick={() => setRenameOpen(false)} className="px-3 py-1.5 text-xs rounded-lg text-muted-foreground hover:bg-muted/50 transition-colors">
Cancel
</button>
<button
onClick={() => void handleRename()}
disabled={busy || !renameValue.trim()}
data-testid="workspace-rename-save"
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Save
</button>
</div>
</div>
</div>
)}
{deleteOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center" onClick={() => setDeleteOpen(false)}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className="relative w-full max-w-sm glass-strong rounded-2xl shadow-2xl p-5" onClick={e => e.stopPropagation()}>
<h2 className="text-sm font-display font-semibold text-foreground mb-2">Delete "{workspace.name}"?</h2>
<p className="text-xs text-muted-foreground mb-3">
This permanently deletes the workspace and everything it remembers {' '}
{memoryCount != null && memoryCount > 0 ? `${memoryCount} ${memoryCount === 1 ? 'memory' : 'memories'}, ` : 'its memories, '}
chats, and files. This can&rsquo;t be undone.
{!isArchived && ' If you just want it out of the way, Archive keeps the memory safe.'}
</p>
<label htmlFor={deleteConfirmInputId} className="block text-xs text-muted-foreground mb-1">
Type <span className="font-medium text-foreground">{workspace.name}</span> to confirm
</label>
<input
id={deleteConfirmInputId}
name="workspace-delete-confirmation"
autoComplete="off"
autoFocus
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && deleteMatches) void handleDelete(); if (e.key === 'Escape') setDeleteOpen(false); }}
data-testid="workspace-delete-confirm-input"
className="w-full px-3 py-2 rounded-xl bg-secondary/30 border border-border text-sm text-foreground focus:outline-none focus:border-destructive/50 focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<div className="flex justify-end gap-2 mt-4">
<button onClick={() => setDeleteOpen(false)} className="px-3 py-1.5 text-xs rounded-lg text-muted-foreground hover:bg-muted/50 transition-colors">
Cancel
</button>
<button
onClick={() => void handleDelete()}
disabled={busy || !deleteMatches}
data-testid="workspace-delete-confirm-button"
className="px-3 py-1.5 text-xs rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
Delete forever
</button>
</div>
</div>
</div>
)}
</>, document.body)}
</>
);
};
export default WorkspaceActionsMenu;

View File

@@ -0,0 +1,327 @@
/**
* WorkspaceBriefing — "home screen" shown in ChatApp when no messages exist.
* Displays workspace context: greeting, memories, decisions, tasks, suggested prompts.
* Fetches from GET /api/workspaces/:id/context.
*/
import { useState, useEffect, useMemo } from 'react';
import {
Brain, Clock, CheckCircle2, AlertTriangle, MessageSquare,
Lightbulb, ChevronRight, Sparkles, ChevronDown, ChevronUp, Wrench,
} from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import type { WorkspaceContext } from '@/lib/types';
import {
readWorkspaceBriefingCollapsed,
writeWorkspaceBriefingCollapsed,
} from '@/lib/workspace-briefing-state';
import { recommendSkills } from '@/lib/skill-recommendations';
import { formatPersonaName } from '@/lib/persona-display';
interface WorkspaceBriefingProps {
workspaceId: string;
/**
* Active workspace's persona id. When set, a "Skills for [Persona]" chip
* row is rendered alongside the existing context-aware suggestedPrompts.
* Optional — when omitted, the chip row is silently skipped.
*/
personaId?: string;
onSendMessage?: (msg: string) => void;
/**
* Pre-fill the input WITHOUT auto-sending. Used by skill chips, where
* the starter is a partial sentence ("Brainstorm ideas for: ") that the
* user must finish before sending. Different from onSendMessage which
* (per ChatApp wiring at line 994) pre-fills and auto-sends after a 1s
* confirm delay.
*/
onPrefill?: (msg: string) => void;
onSelectSession?: (id: string) => void;
}
const WorkspaceBriefing = ({ workspaceId, personaId, onSendMessage, onPrefill, onSelectSession }: WorkspaceBriefingProps) => {
const [ctx, setCtx] = useState<WorkspaceContext | null>(null);
const [loading, setLoading] = useState(true);
// M-23 / ENG-2: collapse state persists per-workspace so it survives
// reload and stays scoped to the current workspace.
const [collapsed, setCollapsedState] = useState(() => readWorkspaceBriefingCollapsed(workspaceId));
// Persona-aware skill chips (Phase 4c). Falls back to universal defaults
// when personaId is missing or unknown — the chip row never strands empty.
const skillChips = useMemo(() => recommendSkills(personaId), [personaId]);
const personaLabel = useMemo(() => formatPersonaName(personaId), [personaId]);
useEffect(() => {
setLoading(true);
setCollapsedState(readWorkspaceBriefingCollapsed(workspaceId));
adapter.getWorkspaceContext(workspaceId)
.then(setCtx)
.catch(() => setCtx(null))
.finally(() => setLoading(false));
}, [workspaceId]);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsedState(next);
writeWorkspaceBriefingCollapsed(workspaceId, next);
};
if (loading) {
// Wave T Lane E fix 3: the chat's entry loading is a thread-shaped skeleton
// (message rhythm: bee-avatar + assistant lines, a right-aligned user bubble)
// instead of a bare centered spinner + "Loading workspace…" — so entering a
// chat reads as "your conversation is loading", not a lie about an empty box.
// sr-only text keeps the screen-reader announcement; reduced-motion stills it.
return (
<div
className="mx-auto w-full max-w-[680px] space-y-4 py-2 animate-pulse motion-reduce:animate-none"
role="status"
aria-label="Loading conversation"
data-testid="chat-thread-skeleton"
>
<span className="sr-only">Loading conversation</span>
<div className="flex gap-2" aria-hidden="true">
<div className="w-9 h-9 shrink-0 rounded-full bg-[var(--surface-2)]" />
<div className="flex-1 space-y-2 pt-0.5">
<div className="h-3 w-28 rounded bg-[var(--surface-2)]" />
<div className="h-3 w-full rounded bg-[var(--surface-2)]" />
<div className="h-3 w-4/5 rounded bg-[var(--surface-2)]" />
</div>
</div>
<div className="flex justify-end" aria-hidden="true">
<div className="h-10 w-2/5 rounded-[4px_14px_14px_14px] bg-[var(--surface-2)]" />
</div>
<div className="flex gap-2" aria-hidden="true">
<div className="w-9 h-9 shrink-0 rounded-full bg-[var(--surface-2)]" />
<div className="flex-1 space-y-2 pt-0.5">
<div className="h-3 w-32 rounded bg-[var(--surface-2)]" />
<div className="h-3 w-11/12 rounded bg-[var(--surface-2)]" />
</div>
</div>
</div>
);
}
if (!ctx) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Brain className="w-8 h-8 text-honey/50" />
<p className="text-sm">Ready to chat</p>
</div>
);
}
const hasContent = ctx.greeting || ctx.summary || (ctx.recentMemories?.length ?? 0) > 0 || (ctx.suggestedPrompts?.length ?? 0) > 0;
// F9: a brand-new workspace (0 sessions) — even one seeded with imported
// memories — must not greet "here's where you left off" / "across 0 sessions".
// View-layer override only; keyed on sessionCount so `stats` absent = old
// behavior. Server greeting/summary are left untouched (other consumers).
const isFirstVisit = ctx.stats?.sessionCount === 0;
const displayGreeting = isFirstVisit
? "This is a fresh workspace — here's what it's set up to do"
: ctx.greeting;
if (collapsed) {
return (
<div className="shrink-0 p-3 flex items-center justify-between border-b border-border/30" data-testid="workspace-briefing-collapsed">
<button
type="button"
onClick={toggleCollapsed}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
data-testid="workspace-briefing-expand"
aria-expanded="false"
>
<ChevronDown className="w-3.5 h-3.5" />
<span className="font-display">
{displayGreeting || (ctx.workspace?.name ? `Briefing for ${ctx.workspace.name}` : 'Briefing')}
</span>
</button>
</div>
);
}
return (
<div className="flex-1 overflow-auto p-6 max-w-2xl mx-auto" data-testid="workspace-briefing-expanded">
{/* Greeting with collapse toggle */}
<div className="mb-6 flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-display font-bold text-foreground mb-1">
{displayGreeting || (ctx.workspace?.name ? `Welcome to ${ctx.workspace.name}` : 'Welcome')}
</h2>
{/* F9: on first visit suppress the "…across 0 sessions" summary and
prefer the template welcome copy. */}
{!isFirstVisit && ctx.summary && (
<p className="text-sm text-muted-foreground">{ctx.summary}</p>
)}
{ctx.welcomeMessage && (isFirstVisit || !ctx.summary) && (
<p className="text-sm text-muted-foreground">{ctx.welcomeMessage}</p>
)}
</div>
<HintTooltip content="Hide briefing">
<button
type="button"
onClick={toggleCollapsed}
className="shrink-0 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted/40"
data-testid="workspace-briefing-collapse"
aria-expanded="true"
>
<ChevronUp className="w-3 h-3" /> Hide
</button>
</HintTooltip>
</div>
{/* Stats bar */}
{ctx.stats && (
<div className="flex gap-4 mb-5 text-[11px] text-muted-foreground">
<span><Brain className="w-3 h-3 inline mr-1" />{ctx.stats.memoryCount} {ctx.stats.memoryCount === 1 ? 'memory' : 'memories'}</span>
{/* F9: "0 sessions" reads as broken on a fresh workspace — hide it until there's ≥1. */}
{ctx.stats.sessionCount > 0 && (
<span><MessageSquare className="w-3 h-3 inline mr-1" />{ctx.stats.sessionCount} sessions</span>
)}
{ctx.lastActive && (
<span><Clock className="w-3 h-3 inline mr-1" />Last active: {new Date(ctx.lastActive).toLocaleDateString(DATE_LOCALE)}</span>
)}
</div>
)}
{/* Pending tasks */}
{ctx.pendingTasks && ctx.pendingTasks.length > 0 && (
<div className="mb-4 p-3 rounded-xl bg-amber-500/5 border border-amber-500/20">
<h3 className="text-xs font-display font-semibold text-amber-400 mb-2">
<AlertTriangle className="w-3 h-3 inline mr-1" />Pending ({ctx.pendingTasks.length})
</h3>
<ul className="space-y-1">
{ctx.pendingTasks.slice(0, 5).map((t, i) => (
<li key={i} className="text-xs text-foreground flex items-start gap-1.5">
<span className="text-amber-400 mt-0.5"></span> {t}
</li>
))}
</ul>
</div>
)}
{/* Recent decisions */}
{ctx.recentDecisions && ctx.recentDecisions.length > 0 && (
<div className="mb-4 p-3 rounded-xl bg-secondary/30 border border-border/30">
<h3 className="text-xs font-display font-semibold text-foreground mb-2">
<Lightbulb className="w-3 h-3 inline mr-1 text-honey" />Recent Decisions
</h3>
<ul className="space-y-1.5">
{ctx.recentDecisions.slice(0, 3).map((d, i) => (
<li key={i} className="text-xs text-muted-foreground">
<span className="text-foreground">{d.content}</span>
{d.date && <span className="text-[11px] text-muted-foreground/60 ml-2">{new Date(d.date).toLocaleDateString(DATE_LOCALE)}</span>}
</li>
))}
</ul>
</div>
)}
{/* Key memories */}
{ctx.recentMemories && ctx.recentMemories.length > 0 && (
<div className="mb-4 p-3 rounded-xl bg-secondary/30 border border-border/30">
<h3 className="text-xs font-display font-semibold text-foreground mb-2">
<Brain className="w-3 h-3 inline mr-1 text-amber-400" />I Remember
</h3>
<ul className="space-y-1.5">
{ctx.recentMemories.slice(0, 5).map((m, i) => (
<li key={i} className="text-xs text-muted-foreground flex items-start gap-1.5">
<span className={`mt-0.5 text-[11px] px-1 rounded ${
m.importance === 'critical' ? 'bg-rose-500/20 text-rose-400' :
m.importance === 'important' ? 'bg-primary/20 text-honey' :
'bg-muted text-muted-foreground'
}`}>{m.importance === 'critical' ? '!' : m.importance === 'important' ? '★' : '·'}</span>
<span className="text-foreground">{m.content.slice(0, 120)}{m.content.length > 120 ? '...' : ''}</span>
</li>
))}
</ul>
</div>
)}
{/* Recent threads */}
{ctx.recentThreads && ctx.recentThreads.length > 0 && (
<div className="mb-4 p-3 rounded-xl bg-secondary/30 border border-border/30">
<h3 className="text-xs font-display font-semibold text-foreground mb-2">
<MessageSquare className="w-3 h-3 inline mr-1 text-sky-400" />Recent Conversations
</h3>
<div className="space-y-1">
{ctx.recentThreads.slice(0, 4).map(t => (
<button key={t.id} onClick={() => onSelectSession?.(t.id)}
className="w-full flex items-center justify-between text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted/50 transition-colors">
{/* Untitled sessions arrive as their raw id ("session-<uuid>") —
never surface a machine slug as a conversation title. */}
<span className="truncate">{/^(session[-_ ]?)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(t.title.trim()) ? 'Untitled conversation' : t.title}</span>
<ChevronRight className="w-3 h-3 shrink-0" />
</button>
))}
</div>
</div>
)}
{/* Cross-workspace hints */}
{ctx.crossWorkspaceHints && ctx.crossWorkspaceHints.length > 0 && (
<div className="mb-4 p-3 rounded-xl bg-violet-500/5 border border-violet-500/20">
<h3 className="text-xs font-display font-semibold text-violet-400 mb-2">
<Sparkles className="w-3 h-3 inline mr-1" />From Other Workspaces
</h3>
<ul className="space-y-1">
{ctx.crossWorkspaceHints.map((h, i) => (
<li key={i} className="text-xs text-muted-foreground">{h}</li>
))}
</ul>
</div>
)}
{/* Phase 4c: Persona-aware skill chips. Pre-fill, never auto-send —
starter templates end with ": " so the user finishes the sentence. */}
{personaId && onPrefill && skillChips.length > 0 && (
<div className="mt-6" data-testid="briefing-skill-chips">
<h3 className="text-xs font-display font-semibold text-muted-foreground mb-2">
<Wrench className="w-3 h-3 inline mr-1 text-honey" />
{personaLabel ? `Skills for ${personaLabel}` : 'Try a skill'}
</h3>
<div className="flex flex-wrap gap-2">
{skillChips.map(chip => (
<button
key={chip.id}
onClick={() => onPrefill(chip.starter)}
className="px-3 py-1.5 text-xs rounded-xl bg-secondary/40 text-foreground hover:bg-secondary/60 hover:text-honey transition-colors border border-border/30"
title={chip.starter}
data-testid={`briefing-skill-chip-${chip.id}`}
>
{chip.label}
</button>
))}
</div>
</div>
)}
{/* Suggested prompts */}
{ctx.suggestedPrompts && ctx.suggestedPrompts.length > 0 && (
<div className="mt-6">
<h3 className="text-xs font-display font-semibold text-muted-foreground mb-2">Get Started</h3>
<div className="flex flex-wrap gap-2">
{ctx.suggestedPrompts.slice(0, 5).map((p, i) => (
<button key={i} onClick={() => onSendMessage?.(p)}
className="px-3 py-1.5 text-xs rounded-xl bg-primary/10 text-honey hover:bg-primary/20 transition-colors border border-primary/20">
{p}
</button>
))}
</div>
</div>
)}
{/* Upcoming schedules */}
{ctx.upcomingSchedules && ctx.upcomingSchedules.length > 0 && (
<div className="mt-4 text-[11px] text-muted-foreground">
<Clock className="w-3 h-3 inline mr-1" />
Upcoming: {ctx.upcomingSchedules.slice(0, 2).join(' · ')}
</div>
)}
</div>
);
};
export default WorkspaceBriefing;

View File

@@ -0,0 +1,455 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bot, Plus, Search, AlertCircle, RefreshCw, LibraryBig, ChevronRight, Network, ArrowRight } from 'lucide-react';
import { Input } from '@/components/ui/input';
import BeeLoader from '@/components/ui/BeeLoader';
import { adapter } from '@/lib/adapter';
import { createSurfaceCache } from '@/lib/surface-cache';
import { useService } from '@/providers/ServiceProvider';
import { useToast } from '@/hooks/use-toast';
import type { Agent, Workspace } from '@/lib/types';
import {
AGENT_CENTER_TABS,
type AgentCenterTab,
filterAgentsByTab,
agentKpis,
formatSuccessRate,
workspaceAmbiguityIds,
shouldSuggestAgents,
SUGGESTED_PERSONA_IDS,
} from '@/lib/agent-center-display';
import { getPersonaById, PERSONAS, type PersonaConfig } from '@/lib/personas';
import AgentCenterRow from './agents/AgentCenterRow';
import AgentCenterDetail from './agents/AgentCenterDetail';
import WorkspacePickerDialog from './agents/WorkspacePickerDialog';
import AgentBuilder, { type AgentBuilderInput } from './agents/AgentBuilder';
import SuggestedAgentCards from './agents/SuggestedAgentCards';
import TemplatesView from './agents/TemplatesView';
import type { BackendPersona } from './agents/types';
/**
* Agent Center (UX-Refactor Phase 3B, S09). Agents as explicit, governed work
* actors over the B3 agents.json store (/api/agents). C22: category tabs =
* All / Personal / Workspace / Team / Autonomous / Archive; Templates (the
* legacy persona catalog + groups) is a SIDE AFFORDANCE, not a tab. C23: Run
* is a one-shot fleet-spawn — on `workspace_ambiguous` a picker opens and the
* run retries with the chosen workspace. Acceptance (§12.9): a user can
* explain what an agent can see and do before enabling it (detail drawer).
*/
interface AgentsAppProps {
workspaces?: Workspace[];
activeWorkspaceId?: string | null;
}
/**
* Pillar 2.6 route-cache: returning to Agents within the session repaints the
* last-loaded roster instantly and refreshes silently (no re-skeleton). The
* roster is a single list (the C22 tab + search filter client-side), so one
* slot suffices; the suggested cards derive from the roster count, so seeding
* the roster seeds them too.
*/
const rosterCache = createSurfaceCache<Agent[]>();
const ROSTER_SLOT = 'roster';
// eslint-disable-next-line react-refresh/only-export-components -- test-only reset for the module-scoped route cache (mirrors clearMemoryListCache)
export function resetAgentsRouteCache(): void {
rosterCache.resetForTests();
}
const AgentsApp = ({ workspaces, activeWorkspaceId }: AgentsAppProps) => {
const { toast } = useToast();
const navigate = useNavigate();
// Cold-load race guard (same fix as HomeCockpit): wait for the adapter's
// initial connect() to settle so a restored window doesn't 401 into a
// spurious "listAgents failed: 401" panel before the session token exists.
const { connecting } = useService();
const [view, setView] = useState<'center' | 'templates'>('center');
const [tab, setTab] = useState<AgentCenterTab>('all');
// Route-cache: seed the roster from the session cache so a return paints
// instantly; loading starts false once the roster has resolved this session
// (so an empty fleet returns to its empty-state, not a fresh BeeLoader).
const [agents, setAgents] = useState<Agent[]>(() => rosterCache.read(ROSTER_SLOT) ?? []);
const [loading, setLoading] = useState(() => !rosterCache.hasResolved(ROSTER_SLOT));
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Agent | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [picker, setPicker] = useState<{ agent: Agent; workspaceIds: string[] } | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createInitial, setCreateInitial] = useState<Partial<AgentBuilderInput> | undefined>(undefined);
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
// Route-cache: only the genuine cold load shows the BeeLoader; once the
// roster has resolved this session a return/reload repaints in place and
// refreshes silently (no spinner over already-shown rows or empty-state).
if (!rosterCache.hasResolved(ROSTER_SLOT)) setLoading(true);
setError(null);
try {
const rows = await adapter.listAgents();
setAgents(rows);
rosterCache.write(ROSTER_SLOT, rows);
// Keep an open detail drawer pointing at the FRESH record (a run/pause
// reload would otherwise leave it showing the stale pre-action status).
setSelected(prev => (prev ? rows.find(a => a.id === prev.id) ?? null : prev));
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load agents');
} finally {
setLoading(false);
}
}, []);
// Defer until the adapter's initial connect attempt has settled (gates on
// `connecting`, not `connected`, so a failed connect still reaches the
// error/Retry UI instead of a permanent skeleton).
useEffect(() => {
if (connecting) return;
void load();
}, [load, connecting]);
/** busyId is shared by run/pause/archive — clear it only if this action
* still owns it, so overlapping actions on two agents can't re-enable a
* row whose own call is still in flight (double-spawn risk). */
const releaseBusy = (id: string) => setBusyId(prev => (prev === id ? null : prev));
const run = async (agent: Agent, workspaceId?: string) => {
setBusyId(agent.id);
try {
const res = await adapter.runAgent(agent.id, workspaceId ? { workspaceId } : {});
const wsName = workspaces?.find((w) => w.id === res.workspaceId)?.name ?? res.workspaceId;
toast({ title: 'Run started', description: `${agent.name}${wsName}` });
await load();
} catch (err) {
// C23: ambiguity → open the workspace picker, then retry with a choice.
const ids = workspaceAmbiguityIds(err);
if (ids && ids.length > 0) {
// Close the detail drawer first: it is a portaled MODAL sheet (z-50 +
// pointer-events lock) that would paint over and inert-ify the picker.
setSelected(null);
setPicker({ agent, workspaceIds: ids });
} else {
toast({ title: 'Run failed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
}
} finally {
releaseBusy(agent.id);
}
};
const pause = async (agent: Agent) => {
setBusyId(agent.id);
try {
await adapter.pauseAgent(agent.id);
toast({ title: 'Paused', description: `${agent.name} — the in-flight run was stopped` });
await load();
} catch (err) {
toast({ title: 'Pause failed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
releaseBusy(agent.id);
}
};
const archiveToggle = async (agent: Agent) => {
setBusyId(agent.id);
try {
await adapter.patchAgent(agent.id, { status: agent.status === 'archived' ? 'idle' : 'archived' });
setSelected(null);
await load();
} catch (err) {
toast({ title: 'Update failed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
releaseBusy(agent.id);
}
};
const create = async (input: AgentBuilderInput) => {
setCreating(true);
try {
const created = await adapter.createAgent(input);
setCreateOpen(false);
setCreateInitial(undefined);
toast({ title: 'Agent created', description: input.name });
await load();
// Open the §12.9 detail surface for the new agent — it carries the Run
// affordance, so "create → review → run" is one continuous flow.
setSelected(created);
} catch (err) {
toast({ title: 'Create failed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
setCreating(false);
}
};
// Single create seam shared by the Templates side affordance and the F-W5C
// sparse suggestion cards, so the two entry points can't drift.
const startFromPersona = useCallback((p: { id: string; name: string; description: string }) => {
setView('center');
setCreateInitial({ personaId: p.id, name: p.name, goal: p.description });
setCreateOpen(true);
}, []);
const useTemplate = (persona: BackendPersona) => startFromPersona(persona);
// F-W5C: curated personas offered when the fleet is near-empty (resolved once).
const suggestedPersonas = useMemo(
() => SUGGESTED_PERSONA_IDS
.map(getPersonaById)
.filter((p): p is PersonaConfig => !!p),
[],
);
const q = search.trim().toLowerCase();
const visible = filterAgentsByTab(agents, tab).filter(
(a) => !q || a.name.toLowerCase().includes(q) || a.goal.toLowerCase().includes(q),
);
const kpis = agentKpis(agents);
// F-W5C: show the suggestion block only on the unfiltered 'all' tab with a
// near-empty fleet — never while searching/filtering or once it grows.
const sparse = shouldSuggestAgents({ loading, error: !!error, tab, query: search, agentCount: agents.length });
return (
<div className="flex flex-col h-full">
{/* Header: title + Templates side affordance + create */}
<div className="px-4 py-3 border-b border-border/30 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Bot className="w-4 h-4 text-honey shrink-0" />
<h2 className="text-sm font-display font-bold text-foreground">Agents</h2>
</div>
<div className="flex items-center gap-1.5">
{/* KPI strip (C27: success-rate yes, hours-saved no). Round-7 fix 3b:
lives in the page header as page-level stats — the search row
below breathes. Center view only (Templates isn't the fleet). */}
{view === 'center' && (
<div className="hidden sm:flex items-center gap-3 mr-2 text-[11px] text-muted-foreground shrink-0" data-testid="agent-center-kpis">
<span><span className="text-foreground font-medium tabular-nums">{kpis.total}</span> {kpis.total === 1 ? 'agent' : 'agents'}</span>
<span><span className="text-foreground font-medium tabular-nums">{kpis.running}</span> running</span>
{/* H2: no runs yet → hide the segment rather than showing a dash. */}
{kpis.avgSuccessRate !== null && (
<span>avg success <span className="text-foreground font-medium tabular-nums">{formatSuccessRate(kpis.avgSuccessRate)}</span></span>
)}
</div>
)}
<button
onClick={() => setView(view === 'templates' ? 'center' : 'templates')}
aria-pressed={view === 'templates'}
className={`flex items-center gap-1 px-2.5 py-1.5 text-[11px] font-medium rounded-lg transition-colors ${
view === 'templates' ? 'bg-primary/20 text-honey' : 'bg-secondary/30 text-muted-foreground hover:text-foreground'
}`}
>
<LibraryBig className="w-3 h-3" /> Templates
</button>
<button
onClick={() => { setCreateInitial(undefined); setCreateOpen(true); }}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="w-3 h-3" /> New Agent
</button>
</div>
</div>
{view === 'templates' ? (
<div className="flex-1 min-h-0">
<TemplatesView onUseTemplate={useTemplate} activeWorkspaceId={activeWorkspaceId} />
</div>
) : (
<>
{/* C22 tab strip + search. All tabs stay in the Tab order
(FilesAppTabs pattern) — a roving tabIndex without arrow-key
handling makes every inactive tab keyboard-unreachable. */}
<div className="px-4 pt-2.5 space-y-2">
<div className="flex flex-wrap gap-1" role="tablist" aria-label="Agent categories">
{AGENT_CENTER_TABS.map((t) => (
<button
key={t.id}
id={`agent-center-tab-${t.id}`}
onClick={() => setTab(t.id)}
role="tab"
aria-selected={tab === t.id}
aria-controls="agent-center-tab-panel"
className={`px-2 py-0.5 rounded-full text-[11px] transition-colors border ${
tab === t.id ? 'border-primary/40 bg-primary/15 text-honey' : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
{/* Search row — KPIs moved to the page header (round-7 fix 3b),
so the field gets the full row to itself. Wave T Lane F item 4:
a visible `--line` border + honey focus ring (focus-within, since
the inner Input suppresses its own ring) so the field holds its
edge on dark — consistent with the marketplace search bar. */}
<div className="flex items-center gap-1.5 bg-muted/50 rounded-lg border border-[var(--line)] px-2 py-1 transition-colors focus-within:border-[var(--honey-line)] focus-within:shadow-[var(--shadow-honey)]">
<Search className="w-3.5 h-3.5 text-muted-foreground" />
<Input
aria-label="Search agents"
name="agentSearch"
autoComplete="off"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search agents..."
className="flex-1 bg-transparent text-xs h-auto border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
{/* Promoted swarm CTA (PR6b §16 / D19) — the waggle-dance entry,
surfaced as a prominent honey banner instead of a buried link.
Navigates to the existing /waggle-dance route. */}
<div className="px-4 pt-2.5">
<button
onClick={() => navigate('/waggle-dance')}
className="group w-full flex items-center gap-3 rounded-xl border px-3.5 py-2.5 text-left transition-colors"
style={{ background: 'var(--honey-wash)', borderColor: 'var(--honey-line)' }}
>
<span
className="grid place-items-center w-8 h-8 rounded-lg shrink-0"
style={{ background: 'var(--honey)', color: '#1a1407' }}
>
<Network className="w-4 h-4" />
</span>
<span className="flex-1 min-w-0">
<span className="block text-xs font-display font-semibold text-foreground">Run a team of agents</span>
<span className="block text-[11px] text-muted-foreground truncate">
waggle-dance · let several specialists coordinate on one goal
</span>
</span>
<span
className="inline-flex items-center gap-1 text-[11px] font-display font-semibold shrink-0"
style={{ color: 'var(--honey-text)' }}
>
Start a swarm <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</span>
</button>
</div>
{/* List */}
<div id="agent-center-tab-panel" className="flex-1 overflow-auto p-2.5" role="tabpanel" aria-labelledby={`agent-center-tab-${tab}`}>
{/* A post-action reload failure must be visible even when a stale
list is still on screen. */}
{error && agents.length > 0 && (
<div role="alert" className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-1.5">
<span className="text-[11px] text-destructive">Refresh failed this list may be stale. {error}</span>
<button onClick={() => void load()} className="inline-flex items-center gap-1 text-[11px] text-honey hover:underline shrink-0">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
)}
{loading && agents.length === 0 ? (
// Wave T Lane F item 3: the signature waggle-dance loader replaces
// the generic arc spinner. BeeLoader owns the status role + SR
// label; the visible caption is aria-hidden to avoid a double read.
<div className="flex flex-col items-center py-12">
<BeeLoader label="Loading agents…" />
<p className="mt-2.5 text-xs text-muted-foreground" aria-hidden>Loading agents</p>
</div>
) : error && agents.length === 0 ? (
<div role="alert" className="text-center py-12">
<AlertCircle className="w-6 h-6 text-destructive/60 mx-auto mb-2" />
<p className="text-xs text-destructive mb-2">{error}</p>
<button onClick={() => load()} className="inline-flex items-center gap-1 text-xs text-honey hover:underline">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
) : visible.length === 0 ? (
<div role="status" aria-live="polite" className="py-12 px-4">
{q || tab !== 'all' ? (
<p className="text-xs text-muted-foreground text-center">No agents match this view.</p>
) : (
<div className="max-w-md mx-auto space-y-4">
{/* The contradiction fix: every workspace already runs a
built-in assistant — an empty custom-agent list must
not read as "nothing is working for you". */}
{workspaces && workspaces.length > 0 && (
<div>
<p className="text-[11px] font-display font-semibold text-[var(--honey-text)] uppercase tracking-wider mb-1.5">
Already working for you
</p>
<ul className="space-y-1">
{workspaces.slice(0, 5).map((ws) => (
<li key={ws.id}>
<button
onClick={() => navigate(`/workspaces/${ws.id}/chat`)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-secondary/30 border border-border/30 hover:border-primary/30 transition-colors text-left"
>
<span className="text-xs text-foreground truncate">
{ws.name}
<span className="text-muted-foreground"> built-in {ws.persona || 'general'} assistant</span>
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
</button>
</li>
))}
</ul>
</div>
)}
<div className="text-center">
<Bot className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">
No custom agents yet create one to automate something specific.
</p>
</div>
</div>
)}
</div>
) : (
<ul className="space-y-1">
{visible.map((a) => (
<AgentCenterRow
key={a.id}
agent={a}
busy={busyId === a.id}
onOpen={setSelected}
onRun={(agent) => void run(agent)}
onPause={(agent) => void pause(agent)}
/>
))}
</ul>
)}
{sparse && (
<SuggestedAgentCards
personas={suggestedPersonas}
onPick={startFromPersona}
allPersonas={PERSONAS}
onBrowseAll={() => setView('templates')}
/>
)}
</div>
</>
)}
{/* Detail drawer (§12.9 explicit scope + traces). */}
<AgentCenterDetail
agent={selected}
workspaces={workspaces}
busy={!!selected && busyId === selected.id}
onOpenChange={(o) => { if (!o) setSelected(null); }}
onRun={(agent) => void run(agent)}
onPause={(agent) => void pause(agent)}
onArchiveToggle={(agent) => void archiveToggle(agent)}
/>
{/* C23 ambiguity picker. */}
{picker && (
<WorkspacePickerDialog
agentName={picker.agent.name}
workspaceIds={picker.workspaceIds}
workspaces={workspaces}
onPick={(wsId) => { const target = picker.agent; setPicker(null); void run(target, wsId); }}
onCancel={() => setPicker(null)}
/>
)}
{/* S18 Agent Builder (Phase 3C) — full §12.9 declaration stepper. */}
{createOpen && (
<AgentBuilder
busy={creating}
initial={createInitial}
workspaces={workspaces}
onCreate={(input) => void create(input)}
onCancel={() => { setCreateOpen(false); setCreateInitial(undefined); }}
/>
)}
</div>
);
};
export default AgentsApp;

View File

@@ -0,0 +1,319 @@
/**
* AllWorkspacesApp (Warm-Hive PR6c · screen 04) — the full workspace shelf.
* Pins the contract:
* - renders a grid card per workspace (name + real stats)
* - the search box filters by name
* - the storage-type pills filter (All / Virtual / Local / Team)
* - a zero-workspace visit shows the create-first empty state (D16)
* - NO-FABRICATION: a workspace with undefined memoryCount renders NO memory
* chip (W2B: not even a filler "—"), never an invented number (PR3/PR3.5)
* - the ENTIRE card opens the workspace (Wave F fix 1b — no floating "Open >"
* link); the actions menu inside stops propagation so managing never opens
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import type { Workspace } from '@/lib/types';
const mocks = vi.hoisted(() => ({
shell: {
workspaces: [] as Workspace[],
workspacesError: null as string | null,
workspacesLoading: false,
selectWorkspace: vi.fn(),
createWorkspace: vi.fn(),
refreshWorkspaces: vi.fn(),
},
}));
vi.mock('@/providers/ShellContext', () => ({ useShell: () => mocks.shell }));
// Wave W (Lane A): drive the entrance choreography's reduced-motion branch
// deterministically. Only useReducedMotion is overridden; every other
// framer-motion export is preserved so the rest of the tree is untouched.
const motionMock = vi.hoisted(() => ({ reduce: false }));
vi.mock('framer-motion', async (importOriginal) => ({
...(await importOriginal<typeof import('framer-motion')>()),
useReducedMotion: () => motionMock.reduce,
}));
// Thin stubs: both reach into ShellContext/adapter/toast internally — out of
// scope for this view's grid/search/filter/empty-state logic.
vi.mock('../WorkspaceActionsMenu', () => ({
// Forward buttonClassName so the rest-affordance tier (Wave U Lane A item 2)
// is assertable on the trigger.
default: ({ workspace, buttonClassName }: { workspace: { id: string }; buttonClassName?: string }) => (
<button data-testid={`actions-${workspace.id}`} className={buttonClassName}>actions</button>
),
}));
vi.mock('../overlays/CreateWorkspaceDialog', () => ({
default: ({ open }: { open: boolean }) => (open ? <div data-testid="create-dialog" /> : null),
}));
import AllWorkspacesApp, { resetWorkspaceShelfCache } from './AllWorkspacesApp';
const ws = (over: Partial<Workspace> & { id: string; name: string }): Workspace => ({
group: 'Personal',
...over,
});
beforeEach(() => {
// Wave U Lane A: the shelf cache/resolution flags are module-scoped — reset
// so each test starts cold (no leak) and non-empty renders resolve at once.
resetWorkspaceShelfCache();
motionMock.reduce = false;
mocks.shell.workspaces = [
ws({ id: 'w1', name: 'Competitive Intelligence', storageType: 'local', memoryCount: 142, sessionCount: 12, health: 'healthy' }),
ws({ id: 'w2', name: 'Pricing Model', storageType: 'virtual', memoryCount: 64, health: 'degraded' }),
ws({ id: 'w3', name: 'Marketing Site', storageType: 'team', memoryCount: 51 }),
// No memoryCount → must render an honest dash, never a fabricated number.
ws({ id: 'w4', name: 'Scratch', storageType: 'virtual' }),
];
mocks.shell.workspacesError = null;
mocks.shell.workspacesLoading = false;
mocks.shell.createWorkspace.mockResolvedValue(undefined);
mocks.shell.refreshWorkspaces.mockResolvedValue(undefined);
});
afterEach(() => { cleanup(); vi.clearAllMocks(); });
describe('AllWorkspacesApp', () => {
it('renders a grid card for every workspace', () => {
render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-grid')).toBeInTheDocument();
expect(screen.getByTestId('all-workspaces-card-w1')).toBeInTheDocument();
expect(screen.getByTestId('all-workspaces-card-w2')).toBeInTheDocument();
expect(screen.getByTestId('all-workspaces-card-w3')).toBeInTheDocument();
expect(screen.getByTestId('all-workspaces-card-w4')).toBeInTheDocument();
expect(screen.getByText('Competitive Intelligence')).toBeInTheDocument();
// Real counts render as-is in the quiet meta row.
expect(screen.getByText(/142 memories/)).toBeInTheDocument();
expect(screen.getByText(/12 sessions/)).toBeInTheDocument();
});
it('renders the newest session title as a quote-styled preview, no "Last:" prefix (Wave S)', () => {
mocks.shell.workspaces = [
ws({ id: 's1', name: 'Sessioned', lastSessionTitle: 'Draft the launch email', lastActive: new Date().toISOString() }),
];
render(<AllWorkspacesApp />);
const card = screen.getByTestId('all-workspaces-card-s1');
expect(card.textContent).toContain('Draft the launch email');
expect(card.textContent).not.toContain('Last:');
});
it('a description still wins over the session-title preview (Wave S priority)', () => {
mocks.shell.workspaces = [
ws({ id: 's2', name: 'Described', description: 'A real description', lastSessionTitle: 'Some session' }),
];
render(<AllWorkspacesApp />);
const card = screen.getByTestId('all-workspaces-card-s2');
expect(card.textContent).toContain('A real description');
expect(card.textContent).not.toContain('Some session');
});
it('suppresses a canned starter greeting from the preview (Wave S honesty contract)', () => {
mocks.shell.workspaces = [
ws({ id: 's3', name: 'Fresh', lastSessionTitle: 'Hello! What can you help me with?' }),
];
render(<AllWorkspacesApp />);
const card = screen.getByTestId('all-workspaces-card-s3');
// Template text is not data — omitted, never paraphrased.
expect(card.textContent).not.toContain('Hello! What can you help me with?');
});
it('reserves the preview slot even when a card has no description/session/activity (Wave T Lane C fix 1 — reserve, don\'t collapse)', () => {
// No description, no session title, no created/lastActive → nothing to preview.
mocks.shell.workspaces = [ws({ id: 'bare', name: 'Bare' })];
render(<AllWorkspacesApp />);
const slot = screen.getByTestId('all-workspaces-preview-bare');
// The slot still renders (its reserved height holds the identity→tags→
// preview→metrics grammar) but carries no fabricated copy — an empty band.
expect(slot).toBeInTheDocument();
expect(slot.textContent).toBe('');
});
it('a duplicate-named card keeps the "duplicate name" pill and rides the slug in its tooltip, never as visible text (Wave T Lane C item 2)', () => {
mocks.shell.workspaces = [
ws({ id: 'twin-alpha', name: 'Twin' }),
ws({ id: 'twin-beta', name: 'Twin' }),
];
render(<AllWorkspacesApp />);
// Both same-named cards keep the collision flag (the pill is kept, not removed).
expect(screen.getAllByText('duplicate name')).toHaveLength(2);
const card = screen.getByTestId('all-workspaces-card-twin-alpha');
const pill = within(card).getByText('duplicate name');
// R12: raw slug = data debris — it rides the pill's tooltip for
// disambiguation, never the resting card face.
expect(pill).toHaveAttribute('title', 'Workspace ID: twin-alpha');
expect(card.textContent).not.toContain('twin-alpha');
});
it('does NOT fabricate a count for a workspace with undefined memoryCount', () => {
render(<AllWorkspacesApp />);
const card = screen.getByTestId('all-workspaces-card-w4');
// W2B: no memory chip at all when the count is absent — no filler dash, and
// no stray digit invented for the missing count.
expect(card.textContent).not.toContain('—');
expect(card.textContent).not.toMatch(/\d+\s*memor/);
});
it('filters by name via the search box', () => {
render(<AllWorkspacesApp />);
const search = screen.getByRole('textbox', { name: /search workspaces by name/i });
expect(search).toHaveAttribute('name', 'workspaceSearch');
expect(search).toHaveAttribute('autocomplete', 'off');
expect(search.className).toContain('focus-visible:ring-2');
fireEvent.change(search, { target: { value: 'pricing' } });
expect(screen.getByTestId('all-workspaces-card-w2')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-card-w1')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-card-w3')).not.toBeInTheDocument();
});
it('shows a "no match" message when search excludes everything', () => {
render(<AllWorkspacesApp />);
fireEvent.change(screen.getByTestId('all-workspaces-search'), { target: { value: 'zzzz-nope' } });
expect(screen.getByTestId('all-workspaces-no-match')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-grid')).not.toBeInTheDocument();
});
it('filters by storage type via the pills (Virtual leaves only virtual workspaces)', () => {
render(<AllWorkspacesApp />);
fireEvent.click(screen.getByTestId('all-workspaces-filter-virtual'));
expect(screen.getByTestId('all-workspaces-card-w2')).toBeInTheDocument(); // virtual
expect(screen.getByTestId('all-workspaces-card-w4')).toBeInTheDocument(); // virtual
expect(screen.queryByTestId('all-workspaces-card-w1')).not.toBeInTheDocument(); // local
expect(screen.queryByTestId('all-workspaces-card-w3')).not.toBeInTheDocument(); // team
});
it('the Local pill leaves only local workspaces', () => {
render(<AllWorkspacesApp />);
fireEvent.click(screen.getByTestId('all-workspaces-filter-local'));
expect(screen.getByTestId('all-workspaces-card-w1')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-card-w2')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-card-w4')).not.toBeInTheDocument();
});
it('hides a storage pill whose count equals the All total (adds no info)', () => {
// Every workspace is virtual → a "Virtual" pill would filter to the same
// set as "All", so only the All pill should render.
mocks.shell.workspaces = [
ws({ id: 'v1', name: 'Alpha', storageType: 'virtual' }),
ws({ id: 'v2', name: 'Beta', storageType: 'virtual' }),
// No storageType → runtime treats absent as virtual (same classify path).
ws({ id: 'v3', name: 'Gamma' }),
];
render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-filter-all')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-filter-virtual')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-filter-local')).not.toBeInTheDocument();
});
it('the whole card opens the workspace; the actions menu does not (Wave F fix 1b)', () => {
const onOpenWorkspace = vi.fn();
render(<AllWorkspacesApp onOpenWorkspace={onOpenWorkspace} />);
// The title (primary click target) opens via the card's click handler…
fireEvent.click(screen.getByTestId('all-workspaces-open-w1'));
expect(mocks.shell.selectWorkspace).toHaveBeenCalledWith('w1');
expect(onOpenWorkspace).toHaveBeenCalledWith('w1');
// …and so does the card surface itself.
fireEvent.click(screen.getByTestId('all-workspaces-card-w2'));
expect(onOpenWorkspace).toHaveBeenCalledWith('w2');
// The actions menu stops propagation — managing must never open.
onOpenWorkspace.mockClear();
fireEvent.click(screen.getByTestId('actions-w3'));
expect(onOpenWorkspace).not.toHaveBeenCalled();
});
it('shows the create-first empty state ONLY after the query resolves genuinely-empty (D16 · Wave U Lane A · R15-V3 loading flag)', () => {
mocks.shell.workspaces = [];
mocks.shell.workspacesLoading = true;
const { rerender } = render(<AllWorkspacesApp />);
// Loading is a distinct state — the empty CTA must not flash first.
expect(screen.getByTestId('all-workspaces-loading')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-empty')).not.toBeInTheDocument();
// Once the real fetch settles, an empty list becomes the empty state.
mocks.shell.workspacesLoading = false;
rerender(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-empty')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-grid')).not.toBeInTheDocument();
// The CTA opens the reused CreateWorkspaceDialog rather than dead-ending.
fireEvent.click(screen.getByTestId('all-workspaces-create-first'));
expect(screen.getByTestId('create-dialog')).toBeInTheDocument();
});
it('renders skeleton cards while the query is unresolved, never the empty CTA prematurely (Wave U Lane A item 1)', () => {
mocks.shell.workspaces = [];
mocks.shell.workspacesLoading = true;
render(<AllWorkspacesApp />);
// Loading · empty · error are three distinct states — skeletons first.
expect(screen.getByTestId('all-workspaces-loading')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-empty')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-create-first')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-grid')).not.toBeInTheDocument();
});
it('a populated query resolves straight to the grid — no skeleton flash', () => {
// Non-empty on first render → resolved synchronously, no loading state.
render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-grid')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-loading')).not.toBeInTheDocument();
});
it('seeds a revisit from the session cache so last-known cards paint instantly (Wave U Lane A item 1)', () => {
// First visit resolves with real cards → populates the module cache.
const { unmount } = render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-grid')).toBeInTheDocument();
unmount();
// A revisit where the live list hasn't rehydrated yet (empty) paints the
// cached shelf instantly — not a skeleton, not the empty state.
mocks.shell.workspaces = [];
render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-grid')).toBeInTheDocument();
expect(screen.getByTestId('all-workspaces-card-w1')).toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-loading')).not.toBeInTheDocument();
expect(screen.queryByTestId('all-workspaces-empty')).not.toBeInTheDocument();
});
it('the card actions kebab is a rest affordance (low opacity), full on hover/focus-within (Wave U Lane A item 2)', () => {
render(<AllWorkspacesApp />);
const kebab = screen.getByTestId('actions-w1');
// Visible at rest (touch + keyboard), not opacity-0 hover-only reveal.
expect(kebab.className).toContain('opacity-60');
expect(kebab.className).not.toContain('opacity-0');
// Full on hover or focus-within (keyboard/focus parity).
expect(kebab.className).toContain('group-hover:opacity-100');
expect(kebab.className).toContain('group-focus-within:opacity-100');
});
it('cascades the shelf in on first paint — staggered card-enter, ~40ms apart (Wave W Lane A item 1)', () => {
render(<AllWorkspacesApp />);
const c1 = screen.getByTestId('all-workspaces-card-w1');
const c2 = screen.getByTestId('all-workspaces-card-w2');
const c3 = screen.getByTestId('all-workspaces-card-w3');
// Reuses the memory surface's card-enter keyframe (8px rise + fade).
expect(c1.style.animation).toContain('card-enter');
// ~40ms/card stagger in grid order; capped so the last card settles ≤500ms.
expect(c1.style.animationDelay).toBe('0ms');
expect(c2.style.animationDelay).toBe('40ms');
expect(c3.style.animationDelay).toBe('80ms');
});
it('does NOT replay the entrance on a filter keystroke — once per surface visit (Wave W Lane A item 1)', () => {
render(<AllWorkspacesApp />);
expect(screen.getByTestId('all-workspaces-card-w1').style.animation).toContain('card-enter');
// Filter down to one card, then clear — w1 re-mounts, but the cascade is
// frozen after the first paint, so it does not rise/fade again.
fireEvent.change(screen.getByTestId('all-workspaces-search'), { target: { value: 'pricing' } });
fireEvent.change(screen.getByTestId('all-workspaces-search'), { target: { value: '' } });
expect(screen.getByTestId('all-workspaces-card-w1').style.animation).toBe('');
});
it('reduced motion opts out of the entrance entirely — no rise/fade (Wave W Lane A item 3)', () => {
motionMock.reduce = true;
render(<AllWorkspacesApp />);
const c1 = screen.getByTestId('all-workspaces-card-w1');
expect(c1.style.animation).toBe('');
expect(c1.style.animationDelay).toBe('');
});
});

View File

@@ -0,0 +1,700 @@
/**
* AllWorkspacesApp — screen 04 "All workspaces" (Warm-Hive PR6c · §4 C1).
*
* The full workspace shelf. Home greets you with the day; THIS is everything —
* every workspace, where it lives, and what's happening in it. A grid of rich
* cards (hex avatar · storage badge · summary · real stats), a name search, and
* storage-type filter pills (All / Virtual / Local / Team). Per-card management
* via the shared WorkspaceActionsMenu; a zero-workspace visit lands on a
* create-your-first empty state rather than dead-ending (D16).
*
* Honesty (PR3/PR3.5 no-fabrication contract): cards render ONLY real Workspace
* fields. memoryCount / sessionCount / health / lastActive are all optional on
* the type — each is gated off (or shown as "—") when absent. We never invent a
* count. The Grid variation ships (D14).
*
* Data + selection reuse the canonical bundle: `useShell()` exposes the single
* `useWorkspaces` instance (list · selectWorkspace · createWorkspace · refresh),
* so this view and the rest of the shell never diverge. The route wrapper passes
* `onOpenWorkspace` to navigate into a workspace (same target HomeCockpit uses,
* `/workspaces/:id`); with no prop it degrades to selection-only.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Search, Plus, Hexagon, AlertTriangle, ArrowRight } from 'lucide-react';
import { useShell } from '@/providers/ShellContext';
import { DATE_LOCALE } from '@/lib/date-locale';
import { isDevNoiseWorkspace } from '@/lib/workspace-counts';
import { SPRING, STAGGER } from '@/lib/motion/tokens';
import { workspaceHeroAvatarId, workspaceHeroNameId, heroMorphEnabled } from '@/lib/motion/hero-morph';
import WorkspaceActionsMenu from '../WorkspaceActionsMenu';
import CreateWorkspaceDialog from '../overlays/CreateWorkspaceDialog';
import { HexAvatar, SectionLabel } from '../warm';
import { accentFor } from '../warm/HexAvatar';
import type { StorageType, Workspace } from '@/lib/types';
/** Phase-0.6 retrofit: the shelf's per-card entrance cadence in ms, sourced from
* the motion vocabulary (STAGGER.list = 0.04s). Pre-scale ONCE so `index * ms`
* stays an exact integer — `index * STAGGER.list * 1000` drifts on float
* (4 * 0.04 * 1000 = 160.00000000000003), which the animationDelay assertions
* would catch. `STAGGER.list * 1000` alone is exactly 40. */
const STAGGER_LIST_MS = STAGGER.list * 1000;
// ── Wave U (Lane A) item 1: session-scoped shelf cache ─────────────────────
// The shelf must show THREE distinct states — loading, empty, error — not
// flash the empty "Create your first workspace" CTA for ~0.5s before the query
// lands. Two module-scoped guards make that honest (mirrors memory-list-cache):
// • shelfSessionCache holds the last resolved workspace list, so a revisit
// within the SPA session paints last-known cards instantly and refreshes in
// the background (cold on reload — a fresh session by design).
// • shelfSessionResolved records that the query resolved ≥once this session,
// so a revisit to a genuinely-empty account resolves instantly.
// R15-V3 s03: ShellContext now forwards useWorkspaces' real `loading` flag, so
// the interim 800ms settle floor (which could still flash the empty state when
// a cold fetch outran it — the judges' "single most trust-damaging frame") is
// replaced by the flag itself.
let shelfSessionCache: Workspace[] | null = null;
let shelfSessionResolved = false;
/** Test-only: reset the module-scoped shelf cache so state can't leak across tests.
* (memory-list-cache keeps this in its own module; the lane is scoped to this
* one file, so the helper co-locates here behind a fast-refresh exemption.) */
// eslint-disable-next-line react-refresh/only-export-components
export function resetWorkspaceShelfCache(): void {
shelfSessionCache = null;
shelfSessionResolved = false;
}
interface AllWorkspacesAppProps {
/**
* Open a workspace — selection is handled here; the wrapper navigates into it
* (the same `/workspaces/:id` target HomeCockpit's onOpenWorkspaceDesktop
* uses). Optional: with no handler the card still selects the workspace.
*/
onOpenWorkspace?: (workspaceId: string) => void;
}
type StorageFilter = 'all' | StorageType;
const STORAGE_FILTERS: { id: StorageFilter; label: string; dot?: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'virtual', label: 'Virtual', dot: 'var(--intel)' },
{ id: 'local', label: 'Local', dot: 'var(--honey)' },
{ id: 'team', label: 'Team', dot: 'var(--healthy)' },
];
/** Storage badge: Local=honey, Virtual=intel, Team=healthy (design §04). */
const STORAGE_BADGE: Record<StorageType, { label: string; color: string; wash: string }> = {
local: { label: 'Local', color: 'var(--honey)', wash: 'var(--honey-wash)' },
virtual: { label: 'Virtual', color: 'var(--intel)', wash: 'var(--intel-wash)' },
team: { label: 'Team', color: 'var(--healthy)', wash: 'var(--healthy-wash)' },
};
function formatRelative(iso?: string): string | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
const mins = Math.round((Date.now() - t) / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.round(hrs / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.round(days / 7);
return `${weeks}w ago`;
}
/**
* Wave S (Lane B) fix 1: session titles that are auto-prefilled starter prompts,
* not user-authored content. The server's `readLastSessionTitle` returns the
* newest session's first message; for a brand-new workspace that's a canned
* starter (DEFAULT_FIRST_MESSAGE / the "brand new workspace" suggested prompt).
* Suppressing these honours the no-fabrication contract — template text is not
* data, so it's omitted from the preview, never paraphrased.
*/
const CANNED_SESSION_TITLES: ReadonlySet<string> = new Set([
'Hello! What can you help me with?',
'What can you do in this workspace?',
]);
/**
* Round-6 fix 1b: honest "Created …" line for description-less cards. Reads
* the server record's `created` ISO stamp (WorkspaceConfig.created — present
* on every list row but not yet declared on the web Workspace type). Relative
* while recent; a plain date once "NNw ago" stops being useful. Never
* fabricated — absent/invalid stamps render nothing.
*/
function formatCreated(iso?: string): string | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
const days = (Date.now() - t) / 86_400_000;
if (days < 60) return `Created ${formatRelative(iso)}`;
return `Created ${new Date(t).toLocaleDateString(DATE_LOCALE)}`;
}
// ── Storage filter pills ──────────────────────────────────────────────────
function FilterPills({
active, counts, onChange,
}: {
active: StorageFilter;
counts: Record<StorageFilter, number>;
onChange: (f: StorageFilter) => void;
}) {
return (
<div role="radiogroup" aria-label="Filter by storage type" className="flex flex-wrap gap-1.5">
{/* W2B: hide never-matching filters — only 'all' plus pills with a count.
W3: also hide a storage pill whose count equals the All total — it
would filter to the same set, so it adds zero information. */}
{STORAGE_FILTERS.filter(f => f.id === 'all' || (counts[f.id] > 0 && counts[f.id] !== counts.all)).map(f => {
const on = active === f.id;
return (
<button
key={f.id}
type="button"
role="radio"
aria-checked={on}
onClick={() => onChange(f.id)}
data-testid={`all-workspaces-filter-${f.id}`}
className={`inline-flex items-center gap-2 rounded-[9px] border px-3.5 py-2 text-[12.5px] font-semibold transition-colors ${
on
? 'border-[var(--honey-line)] bg-[var(--honey-wash)] text-[var(--text)]'
: 'border-[var(--line-soft)] bg-[var(--surface)] text-[var(--text-muted)] hover:text-[var(--text)]'
}`}
>
{f.dot && <span aria-hidden className="h-[7px] w-[7px] rounded-full" style={{ background: f.dot }} />}
{f.label}
<span className="text-[var(--text-dim)]">{counts[f.id]}</span>
</button>
);
})}
</div>
);
}
// ── One workspace card (real fields only) ─────────────────────────────────
function WorkspaceCard({
ws, onOpen, onChanged, isDuplicateName, enterDelayMs, enableMorph = false,
}: {
ws: Workspace;
onOpen: () => void;
onChanged: () => void;
/** True when another workspace shares this name — surface a "duplicate name"
* pill (with the raw slug in its tooltip) so two same-named cards resolve. */
isDuplicateName: boolean;
/** Wave W (Lane A) item 1: staggered-entrance delay (ms) for the once-per-visit
* cascade. Absent → the card renders at rest with no entrance animation. */
enterDelayMs?: number;
/** Lane HM (Pillar 1.1): opt this card's hex avatar + name into the hero
* shared-element morph. When true, both carry the workspace's layoutId so
* opening the card GROWS it into the destination workspace header. Gated off
* under reduced motion (no shared-element travel) and by the kill switch. */
enableMorph?: boolean;
}) {
// Hero-morph ids — only assigned when the morph is live, so the plain card is
// byte-identical when reduced motion / the kill switch is on.
const avatarLayoutId = enableMorph ? workspaceHeroAvatarId(ws.id) : undefined;
const nameLayoutId = enableMorph ? workspaceHeroNameId(ws.id) : undefined;
const badge = ws.storageType ? STORAGE_BADGE[ws.storageType] : null;
const activeAgo = formatRelative(ws.lastActive ?? ws.updatedAt);
// The server list rows carry WorkspaceConfig.created; the web type doesn't
// declare it yet — narrow local read, no fabrication when absent.
const createdLine = formatCreated((ws as Workspace & { created?: string }).created);
// Round-8 v3 fix 2: the activity-preview body for description-less cards.
// Composed from real server stamps only — created + last-active — e.g.
// "Created 3w ago · active 2w ago". No `summary` field exists on the list
// payload (verified in lib/types Workspace), so none is invented. Each part
// is conditional; an all-absent card renders no body line at all.
const activityLine =
[createdLine, activeAgo ? `active ${activeAgo}` : null].filter(Boolean).join(' · ') || null;
// Wave S (Lane B) fix 1: the newest session's title is the most alive thing
// the card can say — quote-styled ("…" · 2w ago), no "Last:" debris. Real
// string from the list payload only (lastSessionTitle). Suppressed when it's
// a canned starter prompt (template text, not user data) so the card falls
// through to the honest created/last-active line instead.
const sessionTitle = ws.lastSessionTitle?.trim();
const sessionPreview =
sessionTitle && !CANNED_SESSION_TITLES.has(sessionTitle) ? sessionTitle : null;
// Wave S (Lane B) fix 2: one live signal per card — a 2px top band in the
// workspace's deterministic accent hue (same hash the avatar uses), at 40%.
// Data-free, differentiates cards without fabrication.
const accent = accentFor(ws.name);
// Honesty: only render a memory count when the field actually exists.
const hasMemoryCount = typeof ws.memoryCount === 'number';
const hasSessionCount = typeof ws.sessionCount === 'number';
return (
// Wave F (fix 1b): the ENTIRE card is the open target — no floating "Open >"
// link. The actions menu inside stops propagation so managing never opens.
// Wave R (Lane B) fix 5: the rest border steps --line-soft → --line in DARK
// ONLY (`:root:not([data-theme=light]) &:not(:hover)`) so dark cards stop
// vanishing on hive-950; light keeps --line-soft and hover keeps honey.
// Wave V (Lane C) motion tier 2: hover/focus-visible answer with a
// motion-safe 2px lift + a honey glow bloom (--shadow-honey) ON TOP of the
// border tier; reduced motion keeps the color tier (border + bloom) and
// drops only the lift (transform gated behind motion-safe), --mo-fast · --mo-ease.
<article
onClick={onOpen}
// Wave W (Lane A) item 1: the shelf's staggered entrance reuses the memory
// surface's `card-enter` keyframe (8px rise + fade, --mo-ease). Fill mode is
// `backwards` (NOT the memory row's `both`): this card carries a hover/focus
// -translate-y lift, and a `forwards`/`both` fill would pin the transform and
// break that tier — `backwards` only holds the hidden start-state during the
// stagger delay, then hands transform back to the hover tier once it settles.
style={enterDelayMs != null ? { animation: 'card-enter var(--mo-slow) var(--mo-ease) backwards', animationDelay: `${enterDelayMs}ms` } : undefined}
className="hive-interactive group relative flex min-h-[132px] cursor-pointer flex-col overflow-hidden rounded-[18px] border border-[var(--line-soft)] [:root:not([data-theme=light])_&:not(:hover)]:border-[var(--line)] bg-[var(--surface)] p-[18px] shadow-[var(--shadow-sm)]"
data-testid={`all-workspaces-card-${ws.id}`}
>
{/* Wave S (Lane B) fix 2: the one live signal — a 2px top band in the
workspace's deterministic accent hue (40% opacity). Decorative. */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[2px]"
style={{ background: `color-mix(in srgb, ${accent} 40%, transparent)` }}
/>
{/* Slot 1 — identity row: avatar + name + storage badge. Lane HM: the
avatar + name are the hero morph's source pair (layoutId), so opening
the card grows them into the workspace header. */}
<div className="mb-2.5 flex items-center gap-3">
<HexAvatar label={ws.name} size={36} layoutId={avatarLayoutId} />
<motion.h2
layoutId={nameLayoutId}
transition={SPRING.expressive}
className="min-w-0 flex-1 truncate text-[16px] font-semibold leading-tight tracking-[-0.01em] text-[var(--text)]"
data-testid={`all-workspaces-open-${ws.id}`}
>
{ws.name}
</motion.h2>
{badge && (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[10.5px] font-semibold"
style={{ color: badge.color, background: badge.wash }}
>
<Hexagon className="h-3 w-3" fill="currentColor" strokeWidth={0} />
{badge.label}
</span>
)}
</div>
{/* Slot 2 — tag row (ALWAYS present, so every card shares the same rows).
The group as a filled chip; the raw slug is out of the resting card
(kw+a11y) — it rides a tooltip. Same-named cards get a subtle
"duplicate name" pill whose tooltip carries the slug to resolve them. */}
<div className="mb-2.5 flex items-center gap-1.5">
<span
className="inline-flex items-center rounded-[6px] bg-[var(--surface-2)] px-2 py-0.5 text-[11px] font-medium text-[var(--text)]"
title={`Workspace ID: ${ws.id}`}
>
{ws.group?.trim() || 'Personal'}
</span>
{isDuplicateName && (
<span
className="inline-flex items-center rounded-[6px] border border-[var(--line)] px-2 py-0.5 text-[11px] font-medium text-[var(--text)]"
title={`Workspace ID: ${ws.id}`}
>
duplicate name
</span>
)}
</div>
{/* Slot 3 — preview line, RESERVED not collapsed (Wave T Lane C fix 1).
The slot always holds a two-line body region so every card shares one
geometry (identity → tags → preview → metrics) whether or not it has a
preview to show — a card with no description/session/activity keeps the
reserved height rather than letting its footer float up out of grammar.
Real data only: a description (2-line clamp), else the quote-styled
newest-session title, else the honest created/last-active line; never a
dead band, never invented copy. The footer's mt-auto still pins metrics
to the shared bottom baseline. */}
<div className="min-h-[39px]" data-testid={`all-workspaces-preview-${ws.id}`}>
{ws.description ? (
<p className="line-clamp-2 text-[13px] leading-[1.5] text-[var(--text-muted)]">
{ws.description}
</p>
) : sessionPreview ? (
<p className="line-clamp-2 text-[13px] leading-[1.5] text-[var(--text-muted)]">
{sessionPreview}
{/* Wave X Lane B: --text-dim fails AA (4.35:1) at 13px on --surface;
the "· 2w ago" suffix reads as secondary by position, not by a
sub-AA color. */}
{activeAgo && <span className="text-[var(--text-muted)]"> · {activeAgo}</span>}
</p>
) : activityLine ? (
<p className="text-[13px] leading-[1.5] text-[var(--text-muted)]">{activityLine}</p>
) : null}
</div>
{/* Slot 4 — metrics footer, pinned to the card's bottom baseline (mt-auto)
so EVERY card's meta row aligns regardless of body length. Real fields
only (W2B honesty: no fabricated count, no filler dash).
Wave X Lane B: --text-dim (#8a8069) on the card's --surface is only
4.35:1 at 12px — below AA. --text-muted (#a3987f) = 5.95:1 on --surface. */}
<div className="mt-auto flex items-center gap-3.5 pt-3 text-[12px] text-[var(--text-muted)]">
{hasMemoryCount && (
<span className="inline-flex items-center gap-1.5">
<Hexagon className="h-3 w-3" strokeWidth={1.8} />
{ws.memoryCount} {ws.memoryCount === 1 ? 'memory' : 'memories'}
</span>
)}
{hasSessionCount && (
<span className="inline-flex items-center gap-1.5">
{ws.sessionCount} {ws.sessionCount === 1 ? 'session' : 'sessions'}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{/* Wave R (Lane B) fix 3: a PERSISTENT quiet "Open →" cue — text-dim at
rest so the whole-card target is always legible, warming to honey on
hover/focus. No longer opacity-0 (the hover-only reveal read as a
missing affordance). Keep it as an explicit keyboard and touch action. */}
<button
type="button"
aria-label={`Open ${ws.name}`}
onClick={(e) => { e.stopPropagation(); onOpen(); }}
className="inline-flex items-center gap-0.5 text-[11px] font-medium text-[var(--text-muted)] transition-colors duration-mo-fast hover:text-[var(--honey-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--honey-line)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)]"
>
Open <ArrowRight className="h-3 w-3" aria-hidden />
</button>
{/* Interactive-within-interactive: keep action clicks out of the card's
open handler so each action has one predictable result. */}
<span onClick={(e) => e.stopPropagation()}>
{/* Wave U (Lane A) fix 2: a rest affordance, not a hover-only reveal
— visible at low opacity at rest (touch + keyboard users can see
it), full on hover or focus-within (the chat action-row tier from
Wave T Lane E: rest ~0.6 → hover/focus-within 1.0). */}
<WorkspaceActionsMenu
workspace={{ id: ws.id, name: ws.name, status: ws.status }}
onChanged={onChanged}
buttonClassName="opacity-60 transition-opacity duration-mo-fast group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
/>
</span>
</div>
</div>
</article>
);
}
// ── Loading state (Wave U Lane A item 1) ──────────────────────────────────
// One skeleton card matching the fixed-slot card geometry (min-h 132 · rounded
// · surface · identity/tag/preview/metrics slots). Decorative — the shelf owns
// the live "Loading workspaces…" announcement.
function ShelfCardSkeleton() {
return (
<div
aria-hidden
className="flex min-h-[132px] flex-col overflow-hidden rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface)] p-[18px] shadow-[var(--shadow-sm)]"
>
{/* Slot 1 — identity: avatar + name + badge */}
<div className="mb-2.5 flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-[10px] bg-[var(--surface-2)]" />
<div className="h-4 flex-1 rounded bg-[var(--surface-2)]" />
<div className="h-5 w-14 shrink-0 rounded-full bg-[var(--surface-2)]" />
</div>
{/* Slot 2 — tag */}
<div className="mb-2.5 flex items-center gap-1.5">
<div className="h-4 w-16 rounded-[6px] bg-[var(--surface-2)]" />
</div>
{/* Slot 3 — preview (reserved two-line body, min-h matches the card) */}
<div className="min-h-[39px] space-y-1.5">
<div className="h-3 w-full rounded bg-[var(--surface-2)]" />
<div className="h-3 w-3/5 rounded bg-[var(--surface-2)]" />
</div>
{/* Slot 4 — metrics footer, pinned to the bottom baseline */}
<div className="mt-auto flex items-center gap-3.5 pt-3">
<div className="h-3 w-20 rounded bg-[var(--surface-2)]" />
<div className="h-3 w-16 rounded bg-[var(--surface-2)]" />
</div>
</div>
);
}
function ShelfLoading() {
return (
<div className="mx-auto h-full max-w-[1000px] overflow-auto px-8 pb-16 pt-7" data-testid="all-workspaces-loading">
<h1 className="mb-1.5 text-[28px] font-semibold tracking-[-0.02em] text-[var(--text)]">Workspaces</h1>
<p className="mb-5 text-[14px] text-[var(--text-muted)]">
Home greets you with the day. This is the full shelf every workspace, where it
lives, and what's happening in it.
</p>
{/* Pulse the whole grid as one unit; motion-reduce holds it steady. */}
<div
aria-hidden
className="grid animate-pulse grid-cols-1 gap-3.5 motion-reduce:animate-none sm:grid-cols-2 lg:grid-cols-3"
>
<ShelfCardSkeleton />
<ShelfCardSkeleton />
<ShelfCardSkeleton />
</div>
<span role="status" className="sr-only">Loading workspaces…</span>
</div>
);
}
// ── Root ──────────────────────────────────────────────────────────────────
const AllWorkspacesApp = ({ onOpenWorkspace }: AllWorkspacesAppProps) => {
const {
workspaces: liveWorkspaces, workspacesError,
selectWorkspace, createWorkspace, refreshWorkspaces, workspacesLoading,
} = useShell();
// Wave U (Lane A) item 1: seed the shelf from the session cache so a revisit
// paints last-known cards instantly; the live list wins the moment it
// (re)arrives non-empty. Every downstream derivation reads this effective list.
const workspaces = liveWorkspaces.length > 0 ? liveWorkspaces : (shelfSessionCache ?? liveWorkspaces);
// Three distinct states (loading · empty · error): never flash the empty
// "Create your first workspace" CTA before the query resolves. The empty
// state may render ONLY once the real fetch has settled (loading false) —
// a session-recorded resolution short-circuits for instant revisits.
const resolved =
workspaces.length > 0 || workspacesError != null || shelfSessionResolved || !workspacesLoading;
useEffect(() => {
if (liveWorkspaces.length > 0) shelfSessionCache = liveWorkspaces;
if (resolved) shelfSessionResolved = true;
}, [liveWorkspaces, resolved]);
const [query, setQuery] = useState('');
const [storageFilter, setStorageFilter] = useState<StorageFilter>('all');
const [showCreate, setShowCreate] = useState(false);
// Wave W (Lane A) items 1+3: the shelf cascades in on its FIRST content paint
// this visit — each card rises 8px + fades on a ~40ms stagger (reusing the
// memory surface's `card-enter` keyframe, 500ms total). `entrancePlayedRef`
// freezes the choreography after that first paint so a later filter keystroke
// (which re-mounts cards) never replays it; reduced motion opts out entirely.
const reduceMotion = !!useReducedMotion();
const entrancePlayedRef = useRef(false);
// Lane HM (Pillar 1.1): the shelf cards are the SOURCE of the card→workspace
// hero morph. Enable it only when motion is allowed and the kill switch is
// off; reduced motion falls back to the default route crossfade (no
// shared-element travel).
const enableMorph = heroMorphEnabled() && !reduceMotion;
// The shelf hides dev/test artefacts (ai-os-audit-*, StressTest-*, E2E-Audit-*…)
// so it reads as the user's real work — matching the switcher/home visible
// count. (Previously the grid was the deliberately-unfiltered "full shelf"; the
// 2026-07 5-judge UX review found the leaked test slugs were the single worst
// in-app frame, so the grid now filters too. Real installs carry no dev noise,
// so a real user sees no change; it also makes the shelf count agree with home.)
const shelfWorkspaces = useMemo(
() => workspaces.filter(w => !isDevNoiseWorkspace(w.name) && w.status !== 'archived'),
[workspaces],
);
// Freeze the entrance stagger once the grid has painted real content this
// visit — a ref (not state) so setting it never re-renders mid-flight and
// strips an in-flight card animation; later renders (filter/re-sort) then read
// it as played and skip the cascade.
useEffect(() => {
if (resolved && shelfWorkspaces.length > 0) entrancePlayedRef.current = true;
}, [resolved, shelfWorkspaces.length]);
// Archived workspaces live under a collapsed disclosure at the bottom of the
// shelf — hidden from the working grid, but still reachable so unarchive
// (via the card's actions menu) stays possible in-UI.
const archivedWorkspaces = useMemo(
() => workspaces.filter(w => !isDevNoiseWorkspace(w.name) && w.status === 'archived'),
[workspaces],
);
const [showArchived, setShowArchived] = useState(false);
// W2B: storageType is persisted only when explicitly set at create (0/56 live
// today); the runtime treats absent as 'virtual' (storage/index.ts default), so
// classify the same way here — otherwise Virtual/Local/Team all read 0.
const counts = useMemo<Record<StorageFilter, number>>(() => {
const base: Record<StorageFilter, number> = { all: shelfWorkspaces.length, virtual: 0, local: 0, team: 0 };
for (const w of shelfWorkspaces) {
base[w.storageType ?? 'virtual'] += 1;
}
return base;
}, [shelfWorkspaces]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return shelfWorkspaces.filter(w => {
if (storageFilter !== 'all' && (w.storageType ?? 'virtual') !== storageFilter) return false;
if (!q) return true;
const haystack = `${w.name} ${w.group ?? ''} ${w.description ?? ''}`.toLowerCase();
return haystack.includes(q);
});
}, [shelfWorkspaces, query, storageFilter]);
// Names shared by more than one workspace — those cards show their group so
// two identically-named workspaces aren't indistinguishable (issue 2b).
const duplicateNames = useMemo(() => {
const counts = new Map<string, number>();
for (const w of shelfWorkspaces) {
const k = w.name.trim().toLowerCase();
counts.set(k, (counts.get(k) ?? 0) + 1);
}
return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([k]) => k));
}, [workspaces]);
const handleOpen = (id: string) => {
selectWorkspace(id);
onOpenWorkspace?.(id);
};
// Loading is the distinct third state — 3 skeleton cards in the shelf
// geometry, never the empty CTA, until the query resolves (Wave U Lane A).
if (!resolved) {
return <ShelfLoading />;
}
// Empty state (D16): a zero-workspace visit gets a create CTA, never a dead end.
if (shelfWorkspaces.length === 0) {
return (
<div className="mx-auto h-full max-w-[1000px] overflow-auto px-8 pb-16 pt-7" data-testid="all-workspaces-empty">
<h1 className="mb-1.5 text-[28px] font-semibold tracking-[-0.02em] text-[var(--text)]">Workspaces</h1>
<p className="mb-6 text-[14px] text-[var(--text-muted)]">Home greets you with the day. This is the full shelf.</p>
{workspacesError && (
<div className="mb-6 flex items-center gap-2.5 rounded-[14px] border border-[var(--risk-line,var(--line-soft))] bg-[var(--risk-wash)] px-4 py-3" role="alert">
<AlertTriangle className="h-4 w-4 shrink-0 text-[var(--risk)]" />
<p className="flex-1 text-[13.5px] text-[var(--text-2)]">Couldn't load your workspaces — they may exist but didn't load.</p>
<button
type="button"
onClick={() => void refreshWorkspaces()}
className="shrink-0 text-[13px] font-medium text-[var(--honey-text)] transition-opacity hover:opacity-80"
data-testid="all-workspaces-retry"
>
Retry
</button>
</div>
)}
<div className="relative overflow-hidden rounded-[26px] border border-[var(--line-soft)] bg-[linear-gradient(150deg,var(--surface),var(--surface-2))] p-10 text-center shadow-[var(--shadow)]">
<span aria-hidden className="pointer-events-none absolute -right-16 -top-16 h-48 w-48 rounded-full bg-[radial-gradient(circle,var(--honey-glow),transparent_70%)]" />
<div className="relative">
<HexAvatar label="W" size={48} className="mx-auto mb-4" />
<h2 className="mb-1.5 text-[19px] font-semibold text-[var(--text)]">No workspaces yet</h2>
<p className="mx-auto mb-6 max-w-md text-[14px] leading-relaxed text-[var(--text-muted)]">
A workspace is one project or area it builds its own memory as you work. Create
your first and it starts remembering.
</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 rounded-[12px] bg-[var(--honey)] px-5 py-2.5 text-[14px] font-medium text-[#1a1407] transition-opacity hover:opacity-90"
data-testid="all-workspaces-create-first"
>
<Plus className="h-4 w-4" /> Create your first workspace
</button>
</div>
</div>
<CreateWorkspaceDialog
open={showCreate}
onClose={() => setShowCreate(false)}
onCreate={(data) => { void createWorkspace(data); }}
/>
</div>
);
}
return (
<div className="mx-auto h-full max-w-[1000px] overflow-auto px-8 pb-16 pt-7" data-testid="all-workspaces">
<div className="mb-1.5 flex items-end gap-4">
<h1 className="text-[28px] font-semibold tracking-[-0.02em] text-[var(--text)]">Workspaces</h1>
<button
type="button"
onClick={() => setShowCreate(true)}
className="ml-auto inline-flex items-center gap-2 rounded-[10px] bg-[var(--honey)] px-4 py-2.5 text-[13px] font-semibold text-[#1a1407] transition-opacity hover:opacity-90"
data-testid="all-workspaces-new"
>
<Plus className="h-3.5 w-3.5" /> New workspace
</button>
</div>
<p className="mb-5 text-[14px] text-[var(--text-muted)]">
Home greets you with the day. This is the full shelf every workspace, where it
lives, and what's happening in it.
</p>
{/* Toolbar: search + storage filter pills */}
<div className="mb-5 flex flex-wrap items-center gap-3">
<div className="flex min-w-[220px] flex-1 items-center gap-2.5 rounded-[11px] border border-[var(--line)] bg-[var(--surface)] px-3.5 py-2.5 focus-within:border-[var(--honey-line)] focus-within:ring-2 focus-within:ring-[var(--honey-line)] focus-within:ring-offset-2 focus-within:ring-offset-[var(--surface)]">
<Search className="h-4 w-4 shrink-0 text-[var(--text-dim)]" aria-hidden />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search workspaces…"
name="workspaceSearch"
autoComplete="off"
aria-label="Search workspaces by name"
data-testid="all-workspaces-search"
className="flex-1 rounded-md border-0 bg-transparent text-[14px] text-[var(--text)] outline-none placeholder:text-[var(--text-dim)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--honey-line)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)]"
/>
</div>
<FilterPills active={storageFilter} counts={counts} onChange={setStorageFilter} />
</div>
<SectionLabel rule className="mb-4">
{filtered.length} {filtered.length === 1 ? 'workspace' : 'workspaces'}
</SectionLabel>
{filtered.length === 0 ? (
<p className="px-4 py-12 text-center text-[14px] text-[var(--text-dim)]" data-testid="all-workspaces-no-match">
No workspaces match.
</p>
) : (
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3" data-testid="all-workspaces-grid">
{filtered.map((ws, i) => (
<WorkspaceCard
key={ws.id}
ws={ws}
// STAGGER.list/card, capped so the last card settles ≤500ms
// (--mo-slow dur + 160ms max delay); frozen after first paint (once-per-visit).
enterDelayMs={reduceMotion || entrancePlayedRef.current ? undefined : Math.min(i, 4) * STAGGER_LIST_MS}
enableMorph={enableMorph}
onOpen={() => handleOpen(ws.id)}
onChanged={() => { void refreshWorkspaces(); }}
isDuplicateName={duplicateNames.has(ws.name.trim().toLowerCase())}
/>
))}
</div>
)}
{/* Archived — collapsed disclosure so the working shelf stays clean but
unarchive (card actions menu) remains reachable in-UI. */}
{archivedWorkspaces.length > 0 && (
<div className="mt-8">
<button
type="button"
onClick={() => setShowArchived(v => !v)}
aria-expanded={showArchived}
data-testid="all-workspaces-archived-toggle"
className="text-[12.5px] font-semibold text-[var(--text-muted)] transition-colors hover:text-[var(--text)]"
>
{showArchived ? '' : ''} Archived ({archivedWorkspaces.length})
</button>
{showArchived && (
<div className="mt-3 grid grid-cols-1 gap-3.5 opacity-70 sm:grid-cols-2 lg:grid-cols-3" data-testid="all-workspaces-archived-grid">
{archivedWorkspaces.map(ws => (
<WorkspaceCard
key={ws.id}
ws={ws}
enableMorph={enableMorph}
onOpen={() => handleOpen(ws.id)}
onChanged={() => { void refreshWorkspaces(); }}
isDuplicateName={false}
/>
))}
</div>
)}
</div>
)}
<CreateWorkspaceDialog
open={showCreate}
onClose={() => setShowCreate(false)}
onCreate={(data) => { void createWorkspace(data); }}
/>
</div>
);
};
export default AllWorkspacesApp;

View File

@@ -0,0 +1,97 @@
/**
* ApprovalsApp (trust surface) — pins the F1 fix: a held `create_skill` awaiting
* approval must never be a blind approve. The card surfaces the skill NAME up
* front and lets the approver expand the EXACT content `writeSkill` will
* persist. `summarizeInput` surfaces none of `{name, content}`, so without the
* SkillPreview the approver saw only the tool name.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import type { ReactNode } from 'react';
const mocks = vi.hoisted(() => ({
adapter: {
getPendingApprovals: vi.fn(),
getApprovalGrants: vi.fn(),
respondApproval: vi.fn(),
revokeApprovalGrant: vi.fn(),
clearApprovalGrants: vi.fn(),
},
toast: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter }));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) }));
// HintTooltip wraps its trigger in a Radix Tooltip that needs a TooltipProvider
// from the app shell — out of scope here, so pass the child through.
vi.mock('@/components/ui/hint-tooltip', () => ({
HintTooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
import ApprovalsApp from './ApprovalsApp';
const heldCreateSkill = (over: Record<string, unknown> = {}) => ({
requestId: 'h1',
toolName: 'create_skill',
source: 'held' as const,
timestamp: Date.now(),
input: { name: 'retry-flaky-fetch', content: '# Retry flaky fetch\nWrap fetch in a 3x backoff.' },
summary: 'Creating skill: retry-flaky-fetch',
riskLevel: 'medium',
...over,
});
beforeEach(() => {
mocks.adapter.getApprovalGrants.mockResolvedValue({ grants: [] });
mocks.adapter.getPendingApprovals.mockResolvedValue({ pending: [] });
});
afterEach(() => { cleanup(); vi.clearAllMocks(); });
describe('ApprovalsApp — held create_skill review', () => {
it("surfaces a held create_skill's name and its full content behind an expander", async () => {
mocks.adapter.getPendingApprovals.mockResolvedValue({ pending: [heldCreateSkill()] });
render(<ApprovalsApp />);
// The skill name is visible without expanding (exact match avoids the
// "Automation: Creating skill: …" line, which merely contains the name).
expect(await screen.findByText('retry-flaky-fetch')).toBeInTheDocument();
// The content stays collapsed until the approver opts to inspect it.
expect(screen.queryByText(/3x backoff/)).not.toBeInTheDocument();
// Expand → the exact bytes writeSkill will persist are shown (a prefix of
// the content plus the rest of the body).
fireEvent.click(screen.getByRole('button', { name: /view skill content/i }));
const body = await screen.findByText(/# Retry flaky fetch/);
expect(body.textContent).toContain('# Retry flaky fetch');
expect(body.textContent).toContain('3x backoff');
// …and can be collapsed again.
fireEvent.click(screen.getByRole('button', { name: /hide skill content/i }));
expect(screen.queryByText(/3x backoff/)).not.toBeInTheDocument();
});
it('does not render a skill preview for a held action without {name, content} (e.g. send_email)', async () => {
mocks.adapter.getPendingApprovals.mockResolvedValue({
pending: [{
requestId: 'h2', toolName: 'send_email', source: 'held', timestamp: Date.now(),
input: { to: 'x@y.z', subject: 'Follow up' }, summary: 'Send follow-up', riskLevel: 'medium',
}],
});
render(<ApprovalsApp />);
// The email recipient still surfaces via summarizeInput…
expect(await screen.findByText(/x@y\.z/)).toBeInTheDocument();
// …but there is no skill-content expander for a non-skill proposal.
expect(screen.queryByRole('button', { name: /skill content/i })).not.toBeInTheDocument();
});
it('surfaces the name+content preview generically for any held {name, content} proposal', async () => {
mocks.adapter.getPendingApprovals.mockResolvedValue({
pending: [heldCreateSkill({ requestId: 'h3', toolName: 'write_file', input: { name: 'notes.md', content: 'hello world body' }, summary: undefined })],
});
render(<ApprovalsApp />);
expect(await screen.findByText('notes.md')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /view skill content/i }));
expect(await screen.findByText(/hello world body/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,449 @@
/**
* ApprovalsApp — Phase B.3 approvals inbox.
*
* Two tabs:
* 1. Pending — live list of approval requests awaiting user decision.
* Approving/denying here routes through the same backend endpoint
* as inline chat approvals.
* 2. Grants — persistent "always allow" decisions. Revoke individually
* or clear the whole list.
*
* This is the trust control surface for enterprise buyers. Every decision
* the user has made about agent autonomy lives here, visible and
* reversible.
*/
import { useState, useEffect, useCallback } from 'react';
import { Shield, ShieldCheck, Clock, X as XIcon, AlertTriangle, CheckCircle2, RefreshCw } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { useToast } from '@/hooks/use-toast';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
import { RiskBadge, riskToneForTool } from './power/power-primitives';
import { createSurfaceCache } from '@/lib/surface-cache';
interface PendingApproval {
requestId: string;
toolName: string;
input: Record<string, unknown>;
timestamp: number;
/** 'held' = a durable L2 action (assist Loop) — no grant path, approve runs it now. */
source?: 'live' | 'held';
riskLevel?: string;
summary?: string | null;
}
interface Grant {
id: string;
toolName: string;
targetKey: string;
sourceWorkspaceId: string | null;
description: string;
grantedAt: string;
expiresAt: string | null;
}
interface ApprovalsCachePayload {
pending: PendingApproval[];
grants: Grant[];
}
/** Preserve the trust inbox while polling/revalidating after a route return. */
const approvalsRouteCache = createSurfaceCache<ApprovalsCachePayload>();
const APPROVALS_CACHE_KEY = 'inbox';
// eslint-disable-next-line react-refresh/only-export-components -- test-only cache reset
export function resetApprovalsRouteCache(): void {
approvalsRouteCache.resetForTests();
}
function formatRelative(iso: string | number): string {
const t = typeof iso === 'number' ? iso : new Date(iso).getTime();
if (!Number.isFinite(t)) return 'unknown';
const diffMs = Date.now() - t;
if (diffMs < 0) return 'just now';
const sec = Math.round(diffMs / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 30) return `${day}d ago`;
const mo = Math.round(day / 30);
if (mo < 12) return `${mo}mo ago`;
return `${Math.round(mo / 12)}y ago`;
}
function summarizeInput(input: Record<string, unknown>): string {
// Show the most informative field for common gated tools — including the
// send_email recipient, the highest-exfiltration-risk field a human must see.
if (input.to ?? input.recipient) {
const to = String(input.to ?? input.recipient);
return `To: ${to}${input.subject ? `${String(input.subject)}` : ''}`;
}
const path = input.path ?? input.file_path ?? input.target_workspace_id;
if (path) return String(path);
if (input.command) return String(input.command).slice(0, 80);
if (input.query) return String(input.query).slice(0, 80);
return '';
}
/**
* A held `create_skill` (and any proposal carrying `{name, content}`) must NOT
* approve blind: the approver sees the skill's name up front and can expand the
* exact bytes `writeSkill` will persist before allowing it. `summarizeInput`
* surfaces none of `{name, content}`, so without this the card showed only the
* tool name — a review-before-apply gap for the self-evolution loop.
*/
function SkillPreview({ name, content }: { name: string; content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="mt-1 mb-2">
<p className="text-[11px] font-display text-[var(--text)]">
Skill: <span className="font-mono font-semibold text-honey">{name}</span>
</p>
<button
onClick={() => setExpanded(e => !e)}
aria-expanded={expanded}
className="mt-1 inline-flex items-center gap-1 text-[10px] text-[var(--text-muted)] hover:text-foreground transition-colors"
>
{expanded ? 'Hide' : 'View'} skill content ({content.length} chars)
</button>
{expanded && (
<pre className="mt-1.5 max-h-48 overflow-auto rounded-md border border-border/30 bg-secondary/30 p-2 text-[10px] leading-relaxed text-[var(--text-muted)] font-mono whitespace-pre-wrap break-words">
{content}
</pre>
)}
</div>
);
}
/** Error state for the trust surface — a failed load must never read as empty. */
const ApprovalsError = ({ message, onRetry, retrying }: { message: string; onRetry: () => void; retrying: boolean }) => (
<div role="alert" className="flex flex-col items-center justify-center h-full py-12 text-center">
<AlertTriangle className="w-10 h-10 text-destructive/60 mb-3" />
<p className="text-sm font-display text-foreground">Couldn't load approvals</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-xs">
The approvals service is unreachable. This is a load error — not an empty inbox. {message}
</p>
<button
onClick={onRetry}
disabled={retrying}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/15 text-honey text-[11px] font-display hover:bg-primary/25 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${retrying ? 'animate-spin' : ''}`} /> Retry
</button>
</div>
);
const ApprovalsApp = () => {
const { toast } = useToast();
const [tab, setTab] = useState<'pending' | 'grants'>('pending');
const cached = approvalsRouteCache.read(APPROVALS_CACHE_KEY);
const [pending, setPending] = useState<PendingApproval[]>(cached?.pending ?? []);
const [grants, setGrants] = useState<Grant[]>(cached?.grants ?? []);
const [loading, setLoading] = useState(() => !approvalsRouteCache.hasResolved(APPROVALS_CACHE_KEY));
const [error, setError] = useState<string | null>(null);
const [clearAllRequested, setClearAllRequested] = useState(false);
const [clearingGrants, setClearingGrants] = useState(false);
const refresh = useCallback(async () => {
if (!approvalsRouteCache.hasResolved(APPROVALS_CACHE_KEY)) setLoading(true);
// allSettled (not Promise.all + per-source .catch): a fetch FAILURE must
// surface as an error on this trust surface, never be coerced into an empty
// inbox. Partial success still renders (a grants failure doesn't hide pending).
const [pendingRes, grantsRes] = await Promise.allSettled([
adapter.getPendingApprovals(),
adapter.getApprovalGrants(),
]);
const previous = approvalsRouteCache.read(APPROVALS_CACHE_KEY);
const nextPending = pendingRes.status === 'fulfilled' ? pendingRes.value.pending ?? [] : previous?.pending ?? [];
const nextGrants = grantsRes.status === 'fulfilled' ? grantsRes.value.grants ?? [] : previous?.grants ?? [];
setPending(nextPending);
setGrants(nextGrants);
if (pendingRes.status === 'fulfilled' && grantsRes.status === 'fulfilled') {
approvalsRouteCache.write(APPROVALS_CACHE_KEY, { pending: nextPending, grants: nextGrants });
}
const failure = pendingRes.status === 'rejected' ? pendingRes.reason
: grantsRes.status === 'rejected' ? grantsRes.reason : null;
setError(failure ? (failure instanceof Error ? failure.message : 'Failed to load approvals') : null);
setLoading(false);
}, []);
useEffect(() => {
refresh();
// Poll for pending approvals every 5s so the inbox stays live.
const interval = setInterval(refresh, 5000);
return () => clearInterval(interval);
}, [refresh]);
const respond = async (req: PendingApproval, approved: boolean, always: boolean) => {
const isHeld = req.source === 'held';
try {
const r = await adapter.respondApproval(req.requestId, approved, { always });
setPending(prev => prev.filter(p => p.requestId !== req.requestId));
if (!approved) {
toast({ title: 'Denied', description: `${req.toolName} will not run.` });
} else if (r?.ok === false) {
// A held action approved but refused/failed at execute (200 {ok:false}).
toast({ title: 'Action could not run', description: r.error, variant: 'destructive' });
} else if (always && !isHeld) {
toast({ title: 'Always allowed', description: `${req.toolName} is now allowed without prompting for this target.` });
refresh();
} else {
toast({ title: 'Approved', description: `${req.toolName} ${isHeld ? 'has run' : 'will run'}.` });
}
} catch {
toast({ title: 'Failed to send response', variant: 'destructive' });
}
};
const revokeGrant = async (grant: Grant) => {
try {
await adapter.revokeApprovalGrant(grant.id);
setGrants(prev => prev.filter(g => g.id !== grant.id));
toast({ title: 'Grant revoked', description: grant.description });
} catch {
toast({ title: 'Failed to revoke grant', variant: 'destructive' });
}
};
const clearAllRequest: ApprovalRequest | null = clearAllRequested ? {
action: 'Revoke all saved approval grants?',
scope: [
`${grants.length} saved grant${grants.length === 1 ? '' : 's'} will be revoked.`,
'Agents will ask again before using those tools or targets.',
'Existing audit history stays intact; this only removes the saved allow decisions.',
],
riskLevel: 'medium',
} : null;
const clearAllGrants = async () => {
if (grants.length === 0) return;
setClearingGrants(true);
try {
await adapter.clearApprovalGrants();
setGrants([]);
setClearAllRequested(false);
toast({ title: 'All grants revoked', description: `${grants.length} grants cleared.` });
} catch {
toast({ title: 'Failed to clear grants', variant: 'destructive' });
} finally {
setClearingGrants(false);
}
};
return (
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/30 shrink-0">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-honey" />
<h3 className="text-sm font-display font-semibold text-foreground">Approvals</h3>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setTab('pending')}
className={`px-2.5 py-1 rounded-md text-[11px] transition-colors ${
tab === 'pending'
? 'bg-primary/15 text-honey'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
Pending {pending.length > 0 && <span className="ml-1 px-1.5 py-0.5 rounded-full bg-[var(--honey-wash)] text-[var(--attention)] text-[10px] font-semibold">{pending.length}</span>}
</button>
<button
onClick={() => setTab('grants')}
className={`px-2.5 py-1 rounded-md text-[11px] transition-colors ${
tab === 'grants'
? 'bg-primary/15 text-honey'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
Grants {grants.length > 0 && <span className="ml-1 text-[10px] text-muted-foreground">({grants.length})</span>}
</button>
<HintTooltip content="Refresh">
<button
type="button"
aria-label="Refresh approvals"
onClick={refresh}
disabled={loading}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</HintTooltip>
</div>
</div>
{/* Pending tab */}
{tab === 'pending' && (
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{error && <ApprovalsError message={error} onRetry={refresh} retrying={loading} />}
{!error && pending.length === 0 && (
<div className="flex flex-col items-center justify-center h-full py-12 text-center">
<ShieldCheck className="w-10 h-10 text-[var(--healthy)] opacity-50 mb-3" />
<p className="text-sm font-display text-foreground">No pending approvals</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-xs">
When an agent tries to run a gated tool, the request will land here for your decision.
</p>
</div>
)}
{pending.map(req => {
const inputSummary = summarizeInput(req.input);
// A held create_skill's args are {name, content} — surfaced via
// SkillPreview so approval is never blind to the skill's bytes.
const skillName = typeof req.input.name === 'string' ? req.input.name : null;
const skillContent = typeof req.input.content === 'string' ? req.input.content : null;
// Held (L2) actions have no "always allow" grant path — approve runs
// them now. Risk is derived from the real tool name (trustworthy).
const isHeld = req.source === 'held';
const risk = riskToneForTool(req.toolName, req.input);
const isElevated = risk !== 'low';
return (
<div
key={req.requestId}
className={`p-3 rounded-[14px] border ${
isElevated
? 'bg-[var(--honey-wash)] border-[var(--honey-line)]'
: 'bg-[var(--surface)] border-[var(--line-soft)]'
}`}
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className={`w-4 h-4 shrink-0 ${isElevated ? 'text-[var(--attention)]' : 'text-[var(--text-muted)]'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<RiskBadge level={risk} />
<p className="text-xs font-display font-semibold text-[var(--text)] truncate">{req.toolName}</p>
</div>
{inputSummary && (
<p className="mt-1 text-[11px] text-[var(--text-muted)] font-mono truncate">{inputSummary}</p>
)}
{isHeld && req.summary && (
<p className="mt-0.5 text-[10px] text-[var(--text-dim)] truncate">Automation: {req.summary}</p>
)}
</div>
</div>
<span className="text-[10px] text-[var(--text-dim)] flex items-center gap-1 shrink-0">
<Clock className="w-2.5 h-2.5" /> {formatRelative(req.timestamp)}
</span>
</div>
{skillName !== null && skillContent !== null && (
<SkillPreview name={skillName} content={skillContent} />
)}
<div className="flex items-center gap-1.5">
{isHeld ? (
// Held (L2): no "always allow" grant path — approve runs it now.
<>
<button
onClick={() => respond(req, true, false)}
className="flex-1 px-2 py-1 rounded-md bg-[var(--healthy-wash)] text-[var(--healthy)] text-[11px] font-display font-semibold hover:brightness-110 transition-[filter]"
>
Approve &amp; run
</button>
<button
onClick={() => respond(req, false, false)}
className="flex-1 px-2 py-1 rounded-md bg-[var(--risk-wash)] text-[var(--risk)] text-[11px] font-display hover:brightness-110 transition-[filter]"
>
Reject
</button>
</>
) : (
<>
<button
onClick={() => respond(req, true, false)}
className="flex-1 px-2 py-1 rounded-md bg-[var(--honey)] text-[#1a1407] text-[11px] font-display font-semibold hover:bg-[var(--honey-bright)] transition-colors"
>
Allow once
</button>
<button
onClick={() => respond(req, true, true)}
className="flex-1 px-2 py-1 rounded-md bg-[var(--healthy-wash)] text-[var(--healthy)] text-[11px] font-display hover:brightness-110 transition-[filter]"
>
Always allow
</button>
<button
onClick={() => respond(req, false, false)}
className="flex-1 px-2 py-1 rounded-md bg-[var(--risk-wash)] text-[var(--risk)] text-[11px] font-display hover:brightness-110 transition-[filter]"
>
Deny
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
{/* Grants tab */}
{tab === 'grants' && (
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{error && <ApprovalsError message={error} onRetry={refresh} retrying={loading} />}
{!error && grants.length === 0 && (
<div className="flex flex-col items-center justify-center h-full py-12 text-center">
<ShieldCheck className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm font-display text-foreground">No saved grants</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-xs">
When you click "Always allow" on an approval, the decision lands here. You can revoke any time.
</p>
</div>
)}
{grants.length > 0 && (
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] text-muted-foreground">
{grants.length} active grant{grants.length === 1 ? '' : 's'}
</p>
<button
onClick={() => setClearAllRequested(true)}
className="text-[11px] text-destructive hover:text-destructive/80 transition-colors"
>
Revoke all
</button>
</div>
)}
{grants.map(grant => (
<div key={grant.id} className="p-3 rounded-xl bg-secondary/30 border border-border/30 flex items-start gap-3">
<CheckCircle2 className="w-4 h-4 text-[var(--healthy)] shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-display font-medium text-foreground">{grant.description}</p>
<p className="text-[10px] text-muted-foreground font-mono mt-0.5">
tool: {grant.toolName}
{grant.sourceWorkspaceId && <> · from: {grant.sourceWorkspaceId}</>}
</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
granted {formatRelative(grant.grantedAt)}
{grant.expiresAt && <> · expires {formatRelative(grant.expiresAt)}</>}
</p>
</div>
<HintTooltip content="Revoke this grant">
<button
type="button"
aria-label={`Revoke grant ${grant.description}`}
onClick={() => revokeGrant(grant)}
className="p-1 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</HintTooltip>
</div>
))}
</div>
)}
<ApprovalModal
request={clearAllRequest}
approveLabel={clearingGrants ? 'Revoking...' : 'Revoke all grants'}
busy={clearingGrants}
onApprove={clearAllGrants}
onCancel={() => {
if (!clearingGrants) setClearAllRequested(false);
}}
/>
</div>
);
};
export default ApprovalsApp;

View File

@@ -0,0 +1,512 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Search, Loader2, FileText, Presentation, Table2, LayoutDashboard, Microscope,
Code2, Image as ImageIcon, Palette, File, Archive, Trash2, RotateCcw, Save, Plus, Link2,
} from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import type { Artifact, ArtifactKind, ArtifactStatus, RelatedSearchResult } from '@/lib/types';
import { DetailDrawer } from '@/components/ui/detail-drawer';
import { StatusBadge } from '@/components/ui/status-badge';
import { Input } from '@/components/ui/input';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
import { cn } from '@/lib/utils';
import { createSurfaceCache, surfaceCacheKey } from '@/lib/surface-cache';
/**
* Artifact Center (UX-Refactor Phase 2C, S05). The outcome layer: produced
* OUTCOMES (decks/docs/sheets/dashboards/research/...) as first-class relational
* objects, not file attachments (PRD §12.5). Cross-workspace by default — workspace
* is a facet, not a hard scope (the differentiator vs the Files app). Backed by the
* A6 artifacts.json index via the /api/artifacts* adapter methods. The detail
* drawer's "Related" section calls the federated search-related endpoint (PRD
* line 532): a topic returns related memories / sessions / tasks too.
*/
const KIND_META: Record<ArtifactKind, { label: string; Icon: typeof FileText }> = {
document: { label: 'Document', Icon: FileText },
presentation: { label: 'Presentation', Icon: Presentation },
spreadsheet: { label: 'Spreadsheet', Icon: Table2 },
dashboard: { label: 'Dashboard', Icon: LayoutDashboard },
research: { label: 'Research', Icon: Microscope },
code: { label: 'Code', Icon: Code2 },
media: { label: 'Media', Icon: ImageIcon },
design: { label: 'Design', Icon: Palette },
other: { label: 'Other', Icon: File },
};
/** Per-kind warm tint for the card icon (design §16 6-color palette → warm semantics, D21). */
const KIND_TINT: Record<ArtifactKind, { bg: string; fg: string }> = {
document: { bg: 'var(--work-wash)', fg: 'var(--work)' },
presentation: { bg: 'var(--honey-wash)', fg: 'var(--honey)' },
spreadsheet: { bg: 'var(--healthy-wash)', fg: 'var(--healthy)' },
dashboard: { bg: 'var(--intel-wash)', fg: 'var(--intel)' },
research: { bg: 'var(--intel-wash)', fg: 'var(--intel)' },
code: { bg: 'var(--work-wash)', fg: 'var(--work)' },
media: { bg: 'var(--honey-wash)', fg: 'var(--honey)' },
design: { bg: 'var(--intel-wash)', fg: 'var(--intel)' },
other: { bg: 'var(--honey-wash)', fg: 'var(--honey)' },
};
const KINDS = Object.keys(KIND_META) as ArtifactKind[];
/** Keyed warm cache for the artifact list; filters and workspace scope matter. */
const artifactRouteCache = createSurfaceCache<Artifact[]>();
// eslint-disable-next-line react-refresh/only-export-components -- test-only cache reset
export function resetArtifactRouteCache(): void {
artifactRouteCache.resetForTests();
}
const STATUS_FILTERS: { value: '' | ArtifactStatus; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'ready', label: 'Ready' },
{ value: 'in_review', label: 'In review' },
{ value: 'final', label: 'Final' },
{ value: 'archived', label: 'Archived' },
];
function statusTone(s: ArtifactStatus): 'neutral' | 'healthy' | 'attention' {
if (s === 'final' || s === 'ready') return 'healthy';
if (s === 'in_review') return 'attention';
return 'neutral'; // draft, archived
}
interface ArtifactCenterAppProps {
activeWorkspaceId?: string;
workspaceName?: string;
}
export default function ArtifactCenterApp({ activeWorkspaceId, workspaceName }: ArtifactCenterAppProps) {
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const [q, setQ] = useState('');
const [kind, setKind] = useState<'' | ArtifactKind>('');
const [status, setStatus] = useState<'' | ArtifactStatus>('');
const listCacheKey = surfaceCacheKey(['artifacts', activeWorkspaceId, q.trim(), kind, status]);
const cachedArtifacts = artifactRouteCache.read(listCacheKey);
const [artifacts, setArtifacts] = useState<Artifact[]>(cachedArtifacts ?? []);
const [loading, setLoading] = useState(() => !artifactRouteCache.hasResolved(listCacheKey));
const [selected, setSelected] = useState<Artifact | null>(null);
const [busy, setBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Artifact | null>(null);
const [deletingArtifact, setDeletingArtifact] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
const [draftKind, setDraftKind] = useState<ArtifactKind>('document');
const [related, setRelated] = useState<RelatedSearchResult | null>(null);
const [relatedLoading, setRelatedLoading] = useState(false);
// Monotonic token so an out-of-order search-related response (artifact A
// resolving after B was opened) cannot render under the wrong artifact (F2).
const relatedReqRef = useRef(0);
const [newTitle, setNewTitle] = useState('');
const [newKind, setNewKind] = useState<ArtifactKind>('document');
const load = useCallback(async () => {
if (!artifactRouteCache.hasResolved(listCacheKey)) setLoading(true);
setError(null);
try {
const res = await adapter.listArtifacts({
q: q.trim() || undefined,
kind: kind || undefined,
status: status || undefined,
limit: 200,
});
setArtifacts(res);
artifactRouteCache.write(listCacheKey, res);
} catch (e) {
if (!artifactRouteCache.hasResolved(listCacheKey)) setArtifacts([]);
setError(e instanceof Error ? e.message : 'Failed to load artifacts');
} finally {
setLoading(false);
}
}, [listCacheKey, q, kind, status]);
useEffect(() => {
const t = setTimeout(load, q ? 250 : 0); // debounce text search only
return () => clearTimeout(t);
}, [load, q]);
const openDetail = (a: Artifact) => {
setSelected(a);
setDraftTitle(a.title);
setDraftKind(a.kind);
setRelated(null);
setRelatedLoading(true);
const reqId = ++relatedReqRef.current;
adapter
.searchRelatedArtifacts(a.title, a.workspaceId)
.then((r) => { if (relatedReqRef.current === reqId) setRelated(r); })
.catch(() => { if (relatedReqRef.current === reqId) setRelated(null); })
.finally(() => { if (relatedReqRef.current === reqId) setRelatedLoading(false); });
};
const mutate = async (fn: () => Promise<unknown>, closeDrawer = false) => {
setBusy(true);
try {
await fn();
if (closeDrawer) setSelected(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : 'Action failed');
} finally {
setBusy(false);
}
};
const create = () => {
if (!activeWorkspaceId || !newTitle.trim()) return;
void mutate(async () => {
await adapter.createArtifact({ title: newTitle.trim(), kind: newKind, workspaceId: activeWorkspaceId });
setNewTitle('');
});
};
const saveEdits = () => {
if (!selected) return;
const patch: { title?: string; kind?: ArtifactKind } = {};
if (draftTitle.trim() && draftTitle !== selected.title) patch.title = draftTitle.trim();
if (draftKind !== selected.kind) patch.kind = draftKind;
if (Object.keys(patch).length === 0) { setSelected(null); return; }
void mutate(() => adapter.patchArtifact(selected.id, patch, selected.workspaceId), true);
};
const archive = (a: Artifact) => void mutate(() => adapter.archiveArtifact(a.id, a.workspaceId), true);
// Restore the pre-archive status the server stashed in prevStatus (A8 faithful
// reversibility), falling back to 'draft' only when none was recorded (F3).
const unarchive = (a: Artifact) => void mutate(() => adapter.patchArtifact(a.id, { status: a.prevStatus ?? 'draft' }, a.workspaceId), true);
const deleteRequest: ApprovalRequest | null = deleteTarget ? {
action: `Delete artifact permanently: ${deleteTarget.title}`,
scope: [
'This removes the artifact record from Waggle.',
'The backing file, if any, is left in place.',
'Use Archive instead if you only want to hide it from active views.',
],
riskLevel: 'medium',
} : null;
const remove = (a: Artifact) => setDeleteTarget(a);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeletingArtifact(true);
setBusy(true);
try {
await adapter.deleteArtifact(deleteTarget.id, deleteTarget.workspaceId);
setSelected(null);
setDeleteTarget(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : 'Action failed');
} finally {
setDeletingArtifact(false);
setBusy(false);
}
};
const relatedCount = related
? related.memories.length + related.sessions.length + related.tasks.length
: 0;
return (
<div className="flex flex-col h-full">
{/* Filter + create bar */}
<div className="border-b border-border/50 p-2.5 space-y-2 bg-background/60">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 bg-muted/50 rounded-lg border border-[var(--line)] px-2 py-1 flex-1 transition-colors focus-within:border-[var(--honey-line)] focus-within:shadow-[var(--shadow-honey)]">
<Search className="w-3.5 h-3.5 text-muted-foreground" />
<Input
aria-label="Search artifacts"
name="artifactSearch"
autoComplete="off"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search artifacts..."
className="flex-1 bg-transparent text-xs h-auto border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map((s) => (
<button
key={s.value || 'all'}
onClick={() => setStatus(s.value)}
aria-pressed={status === s.value}
className={cn(
'px-2 py-0.5 rounded-full text-[11px] transition-colors border',
status === s.value ? 'border-primary/40 bg-primary/15 text-honey' : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground',
)}
>
{s.label}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
<button
onClick={() => setKind('')}
aria-pressed={kind === ''}
className={cn('px-1.5 py-0.5 rounded text-[11px] transition-colors', kind === '' ? 'bg-primary/20 text-honey' : 'bg-muted/50 text-muted-foreground hover:text-foreground')}
>
All kinds
</button>
{KINDS.map((k) => (
<button
key={k}
onClick={() => setKind(kind === k ? '' : k)}
aria-pressed={kind === k}
className={cn('px-1.5 py-0.5 rounded text-[11px] transition-colors', kind === k ? 'bg-primary/20 text-honey' : 'bg-muted/50 text-muted-foreground hover:text-foreground')}
>
{KIND_META[k].label}
</button>
))}
</div>
{activeWorkspaceId && (
<div className="flex items-center gap-1.5">
<Input
aria-label="New artifact title"
name="artifactTitle"
autoComplete="off"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') create(); }}
placeholder={`New artifact in ${workspaceName ?? 'this workspace'}`}
className="flex-1 text-xs h-7"
/>
<select
name="artifactKind"
autoComplete="off"
value={newKind}
onChange={(e) => setNewKind(e.target.value as ArtifactKind)}
className="text-[11px] rounded-md border border-border bg-muted/40 px-2 py-1 text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="New artifact kind"
>
{KINDS.map((k) => <option key={k} value={k}>{KIND_META[k].label}</option>)}
</select>
<button
onClick={create}
disabled={busy || !newTitle.trim()}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary text-primary-foreground text-[11px] font-medium hover:bg-primary/90 disabled:opacity-50"
>
<Plus className="w-3 h-3" /> Add
</button>
</div>
)}
</div>
{/* List */}
<div className="flex-1 overflow-auto p-2.5">
{loading && artifacts.length === 0 ? (
<div role="status" aria-live="polite" className="text-center py-12"><Loader2 className="w-6 h-6 text-muted-foreground/40 mx-auto mb-2 animate-spin" /><p className="text-xs text-muted-foreground">Loading artifacts</p></div>
) : error && artifacts.length === 0 ? (
<div role="alert" className="text-center py-12">
<p className="text-xs text-destructive mb-2">{error}</p>
<button onClick={() => load()} className="text-xs text-honey hover:underline">Retry</button>
</div>
) : artifacts.length === 0 ? (
<div role="status" aria-live="polite" className="text-center py-12">
<FileText className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">
{q || kind || status ? 'No artifacts match these filters.' : 'No artifacts yet — outcomes your agents produce will appear here.'}
</p>
{/* Teach-and-invite (5-judge finding: "all mood, no sell — no CTA").
Ghost tiles show WHAT will appear; the CTA routes to a chat. */}
{!q && !kind && !status && (
<>
<div className="mt-5 flex items-center justify-center gap-2.5" aria-hidden>
{(['document', 'presentation', 'spreadsheet'] as ArtifactKind[]).map(k => {
const { label, Icon } = KIND_META[k];
return (
<span key={k} className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-border/50 bg-muted/20 px-3 py-2 text-[11px] text-muted-foreground/70">
<Icon className="w-3.5 h-3.5" /> {label}
</span>
);
})}
</div>
<button
onClick={() => navigate('/workspaces')}
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-xs font-display font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
Ask Waggle to make something
</button>
</>
)}
</div>
) : (
<>
{/* A transient refetch error must not wipe an already-populated list
(F5): show it as an inline banner instead of the full-pane error. */}
{error && (
<div role="alert" className="mb-2 flex items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-1.5">
<span className="text-[11px] text-destructive">{error}</span>
<button onClick={() => load()} className="text-[11px] text-honey hover:underline shrink-0">Retry</button>
</div>
)}
{/* 3-column card grid (PR6b §16) — provenance-forward outcome
tiles, not a flat list. Collapses to 2/1 cols on narrow panes. */}
<ul className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2.5">
{artifacts.map((a) => {
const { Icon, label } = KIND_META[a.kind];
const tint = KIND_TINT[a.kind];
return (
<li key={a.id}>
<button
onClick={() => openDetail(a)}
className="group w-full h-full flex flex-col gap-2 rounded-xl border border-border/60 bg-card/40 p-3 text-left hover:border-primary/40 hover:-translate-y-0.5 transition-[border-color,transform]"
>
<div className="flex items-start justify-between gap-2">
<span
className="grid place-items-center w-9 h-10 rounded-lg shrink-0"
style={{ background: tint.bg }}
>
<Icon className="w-4 h-4" style={{ color: tint.fg }} />
</span>
<StatusBadge tone={statusTone(a.status)} label={a.status.replace('_', ' ')} />
</div>
<div className="min-w-0">
<span className="block text-xs font-medium truncate">{a.title}</span>
<span className="block text-[10px] text-muted-foreground truncate">
{label} · {new Date(a.updatedAt).toLocaleDateString(DATE_LOCALE)}
</span>
</div>
{/* Provenance — gated: render the real source string the
backend stored; never fabricate a creator (D11/D20). */}
{a.source && (
<span className="text-[10px] truncate" style={{ color: 'var(--intel)' }}>
{a.source}
</span>
)}
</button>
</li>
);
})}
</ul>
</>
)}
</div>
{/* Detail drawer */}
<DetailDrawer
open={!!selected}
onOpenChange={(o) => { if (!o) setSelected(null); }}
title={selected?.title ?? 'Artifact'}
subtitle={selected ? `${KIND_META[selected.kind].label} · ${selected.source}` : undefined}
headerExtra={selected ? <StatusBadge tone={statusTone(selected.status)} label={selected.status.replace('_', ' ')} /> : undefined}
footer={selected ? (
<div className="flex items-center gap-2 w-full">
<button onClick={saveEdits} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50">
<Save className="w-3 h-3" /> Save
</button>
{selected.status === 'archived' ? (
<button onClick={() => unarchive(selected)} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-border text-xs hover:bg-muted">
<RotateCcw className="w-3 h-3" /> Unarchive
</button>
) : (
<button onClick={() => archive(selected)} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-border text-xs hover:bg-muted">
<Archive className="w-3 h-3" /> Archive
</button>
)}
<button onClick={() => remove(selected)} disabled={busy} className="ml-auto inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs text-destructive hover:bg-destructive/10">
<Trash2 className="w-3 h-3" /> Delete
</button>
</div>
) : undefined}
>
{selected && (
<>
<div>
<label htmlFor="ac-draft-title" className="text-[11px] font-display font-semibold uppercase tracking-wide text-muted-foreground">Title</label>
<Input
id="ac-draft-title"
name="artifactDraftTitle"
autoComplete="off"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
className="mt-1 text-sm h-8"
/>
</div>
<div>
<label htmlFor="ac-draft-kind" className="text-[11px] font-display font-semibold uppercase tracking-wide text-muted-foreground">Kind</label>
<select
id="ac-draft-kind"
name="artifactDraftKind"
autoComplete="off"
value={draftKind}
onChange={(e) => setDraftKind(e.target.value as ArtifactKind)}
className="mt-1 block w-full text-xs rounded-md border border-border bg-muted/40 px-2 py-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{KINDS.map((k) => <option key={k} value={k}>{KIND_META[k].label}</option>)}
</select>
</div>
{selected.tags && selected.tags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{selected.tags.map((t) => <span key={t} className="text-[11px] text-muted-foreground">#{t}</span>)}
</div>
)}
{selected.storagePath && (
<p className="text-[11px] text-muted-foreground break-all">
<span className="font-semibold">Path:</span> {selected.storagePath}
</p>
)}
{/* Related — the federated search-related endpoint (PRD line 532) */}
<div>
<p className="text-[11px] font-display font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Link2 className="w-3 h-3" /> Related {relatedLoading ? '' : `(${relatedCount})`}
</p>
{relatedLoading ? (
<p className="mt-1 text-[11px] text-muted-foreground">Finding related items</p>
) : relatedCount === 0 ? (
<p className="mt-1 text-[11px] text-muted-foreground">No related memories, sessions, or tasks found.</p>
) : (
<div className="mt-1 space-y-1.5">
{related!.memories.length > 0 && (
<RelatedGroup label="Memories" items={related!.memories.map((m) => ({ id: m.id, text: m.title }))} />
)}
{related!.tasks.length > 0 && (
<RelatedGroup label="Tasks" items={related!.tasks.map((t) => ({ id: t.id, text: t.title }))} />
)}
{related!.sessions.length > 0 && (
<RelatedGroup label="Sessions" items={related!.sessions.map((s) => ({ id: s.id, text: s.title }))} />
)}
</div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
Created {new Date(selected.createdAt).toLocaleString(DATE_LOCALE)}
{` · updated ${new Date(selected.updatedAt).toLocaleString(DATE_LOCALE)}`}
</p>
</>
)}
</DetailDrawer>
<ApprovalModal
request={deleteRequest}
approveLabel={deletingArtifact ? 'Deleting...' : 'Delete artifact'}
busy={deletingArtifact}
onApprove={() => { void confirmDelete(); }}
onCancel={() => {
if (!deletingArtifact) setDeleteTarget(null);
}}
/>
</div>
);
}
function RelatedGroup({ label, items }: { label: string; items: { id: string; text: string }[] }) {
return (
<div>
<p className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">{label}</p>
<ul className="mt-0.5 space-y-0.5">
{items.map((it) => (
<li key={it.id} className="text-[11px] text-foreground/80 truncate"> {it.text}</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,750 @@
import { useState, useEffect, useCallback } from 'react';
import { Clock, Plus, Loader2, AlertTriangle, RefreshCw, Check, X, ShieldAlert } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import { useService } from '@/providers/ServiceProvider';
import { useToast } from '@/hooks/use-toast';
import type { Automation } from '@waggle/shared';
import { LOOP_TEMPLATES, type LoopTemplate } from '@waggle/shared';
import type { AutomationLog, Workspace, EngineStatus, PendingApprovalItem } from '@/lib/types';
import { consumeDeepLink } from '@/lib/app-deeplink';
import { successRateFromLogs, formatRatePercent, describeTrigger, workspaceLabel, groupAutomationsByWorkspace, describeActionTarget } from '@/lib/automation-display';
import AutomationRow from './automations/AutomationRow';
import AutomationLogList, { type NamedLog } from './automations/AutomationLogList';
import AutomationBuilder, { type AutomationDraft } from './automations/AutomationBuilder';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
/**
* Automation Center (UX-Refactor Phase 3B, S11 — rename/extension of the
* cron-backed ScheduledJobsApp; AppId 'scheduled-jobs' stays stable per B1).
* PRD §12.10 acceptance: overnight work is visible, reviewable, stoppable.
* Tabs: Overview / Running / Scheduled / Triggers / History / Logs.
* - C24: schedule-only triggers v1 (+ manual). C25: condition advisory.
* - C26: form "Check configuration" = validation-only preview.
* - C27: success-rate derives client-side from execution logs; no
* "hours saved" (no data source).
* Journey 16: Home Cockpit failure items deep-link here via the
* `waggle:open-app` event carrying `{ appId: 'scheduled-jobs', tab: 'logs' }`.
*/
type CenterTab = 'overview' | 'running' | 'scheduled' | 'triggers' | 'history' | 'logs';
const TABS: ReadonlyArray<{ id: CenterTab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'running', label: 'Running' },
{ id: 'scheduled', label: 'Scheduled' },
{ id: 'triggers', label: 'Triggers' },
{ id: 'history', label: 'History' },
{ id: 'logs', label: 'Logs' },
];
const AutomationCenterApp = () => {
const { toast } = useToast();
// Cold-load race guard (same fix as HomeCockpit): the adapter attaches the
// session token during its initial connect(); firing authed calls from a
// restored window before that 401s into a spurious error panel.
const { connecting } = useService();
const [tab, setTab] = useState<CenterTab>('overview');
const [automations, setAutomations] = useState<Automation[]>([]);
const [logsMap, setLogsMap] = useState<Record<string, AutomationLog[]>>({});
/** Automations whose log fetch failed — their badges/KPIs are incomplete. */
const [logsFailedIds, setLogsFailedIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<Automation | null>(null);
const [saving, setSaving] = useState(false);
const [runningIds, setRunningIds] = useState<Set<string>>(new Set());
const [logsTarget, setLogsTarget] = useState<string | null>(null);
// Multi-workspace grouping + the Loops-engine sovereignty pill.
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [engine, setEngine] = useState<EngineStatus | null>(null);
/** Template currently being created (disables its button). */
const [creatingTemplateId, setCreatingTemplateId] = useState<string | null>(null);
/** Which workspace a template-created Loop should run in ('' = all / personal). */
const [templateWorkspaceId, setTemplateWorkspaceId] = useState('');
/** L2: held actions awaiting the user's approval (source:'held'). */
const [pendingActions, setPendingActions] = useState<PendingApprovalItem[]>([]);
/** Held actions whose approve/reject is in flight (per-row lock). */
const [decidingIds, setDecidingIds] = useState<Set<string>>(new Set());
/** Template create: assist mode (the Loop proposes actions for approval). */
const [assistMode, setAssistMode] = useState(false);
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rows = await adapter.listAutomations();
setAutomations(rows);
// C27: success-rate + Failed badges derive from execution history —
// fetch per-automation logs (cron lists are small; bounded limit each).
// Failed fetches are TRACKED, not dropped: a missing log must read as
// "history unavailable", never as a healthy automation.
const results = await Promise.allSettled(rows.map(a => adapter.getAutomationLogs(a.id, 20)));
const map: Record<string, AutomationLog[]> = {};
const failed: string[] = [];
rows.forEach((a, i) => {
const r = results[i];
if (r.status === 'fulfilled') map[a.id] = r.value;
else failed.push(a.id);
});
setLogsMap(map);
setLogsFailedIds(failed);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load automations');
} finally {
setLoading(false);
}
}, []);
// Defer until the adapter's initial connect attempt has settled (gates on
// `connecting`, not `connected`, so a failed connect still reaches the
// error/Retry UI instead of a permanent skeleton).
useEffect(() => {
if (connecting) return;
void refresh();
}, [refresh, connecting]);
// Workspaces (for grouping) load once; the engine status polls so the
// sovereignty pill reflects whether the local Loops engine is live right now.
useEffect(() => {
if (connecting) return;
let active = true;
adapter.getWorkspaces().then(ws => { if (active) setWorkspaces(ws); }).catch(() => {});
const poll = () => {
adapter.getEngineStatus()
.then(s => { if (active) setEngine(s); })
.catch(() => { if (active) setEngine(null); });
// L2: held actions an assist-mode Loop drafted, awaiting approval.
adapter.getPendingApprovals()
.then(r => { if (active) setPendingActions(r.pending.filter(p => p.source === 'held')); })
.catch(() => { if (active) setPendingActions([]); });
};
void poll();
const timer = setInterval(() => void poll(), 30_000);
return () => { active = false; clearInterval(timer); };
}, [connecting]);
// Journey 16 / M-09: Home failure items open this app on a specific tab,
// optionally preselecting the failing automation's log. Two paths:
// - cold open: the dispatch happened BEFORE this component mounted (its
// listener didn't exist yet) — Desktop stashed the intent, consumed once
// here on mount;
// - already mounted: the live event handler applies the detail directly
// (and drops the stashed copy so a later remount can't replay it).
const applyDeepLink = useCallback((detail: { tab?: string; automationId?: string }) => {
if (detail.tab && TABS.some(t => t.id === detail.tab)) setTab(detail.tab as CenterTab);
if (detail.automationId) setLogsTarget(detail.automationId);
}, []);
useEffect(() => {
const pending = consumeDeepLink('scheduled-jobs');
if (pending) applyDeepLink(pending);
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { appId?: string; tab?: string; automationId?: string } | undefined;
if (detail?.appId === 'scheduled-jobs') {
consumeDeepLink('scheduled-jobs');
applyDeepLink(detail);
}
};
window.addEventListener('waggle:open-app', handler);
return () => window.removeEventListener('waggle:open-app', handler);
}, [applyDeepLink]);
const runNow = async (a: Automation) => {
setRunningIds(prev => new Set(prev).add(a.id));
try {
const result = await adapter.runAutomation(a.id);
toast({
title: 'Automation triggered',
description: a.triggerType === 'manual'
? 'Ran now — stays manual, no schedule was enabled'
: result.autoEnabled ? 'Running now — schedule re-enabled' : 'Running now',
});
await refresh();
} catch {
toast({ title: 'Failed to trigger automation', variant: 'destructive' });
} finally {
setRunningIds(prev => { const next = new Set(prev); next.delete(a.id); return next; });
}
};
const toggle = async (a: Automation) => {
try {
if (a.status === 'active' || a.status === 'running') {
await adapter.pauseAutomation(a.id);
} else {
await adapter.updateAutomation(a.id, { enabled: true });
}
await refresh();
} catch {
toast({ title: 'Failed to update automation', variant: 'destructive' });
}
};
const remove = async (a: Automation) => {
setPendingDelete(a);
};
const deleteApproval: ApprovalRequest | null = pendingDelete
? {
action: `Delete automation: ${pendingDelete.name}`,
riskLevel: 'medium',
scope: [
'Deletes this automation schedule from this machine.',
'Its run history goes with it.',
'This cannot be undone.',
],
}
: null;
const confirmDelete = async () => {
if (!pendingDelete) return;
const target = pendingDelete;
setDeletingId(target.id);
try {
await adapter.deleteCronJob(target.id);
toast({ title: 'Automation deleted' });
setPendingDelete(null);
await refresh();
} catch {
toast({ title: 'Failed to delete automation', variant: 'destructive' });
} finally {
setDeletingId(null);
}
};
const submit = async (draft: AutomationDraft) => {
setSaving(true);
try {
if (editing) {
await adapter.updateAutomation(editing.id, {
name: draft.name,
trigger: draft.trigger,
...(draft.condition !== undefined ? { condition: draft.condition } : {}),
// 3C: the Builder now edits the action config (prompt/output
// channel) — the server merges this over the stored job_config blob.
...(draft.jobConfig !== undefined ? { jobConfig: draft.jobConfig } : {}),
// A manual row is stored disabled; switching it back to a schedule
// must go live — otherwise the saved schedule silently never fires
// until a separate Enable toggle.
...(editing.triggerType === 'manual' && draft.trigger.type === 'schedule'
? { enabled: true }
: {}),
});
setEditing(null);
toast({ title: 'Automation updated', description: draft.name });
} else {
await adapter.createAutomation({ ...draft, enabled: true });
setCreating(false);
toast({ title: 'Automation created', description: draft.name });
}
await refresh();
} catch (err) {
toast({ title: editing ? 'Failed to update automation' : 'Failed to create automation', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
setSaving(false);
}
};
// One-click create a report-only Loop from a knowledge-worker template. Loops
// carry their own prompt, so they are created from templates (not the generic
// Builder job-type dropdown) — the template is the safe, prefilled starting point.
const createFromTemplate = async (t: LoopTemplate) => {
setCreatingTemplateId(t.id);
try {
await adapter.createAutomation({
name: assistMode ? `${t.name} (assist)` : t.name,
trigger: { type: 'schedule', cron: t.defaultCron },
jobType: 'loop',
// assist mode lets the Loop propose ONE action per run for your approval
// (still toolless — nothing runs until you approve).
jobConfig: assistMode ? { ...t.jobConfig, mode: 'assist' } : t.jobConfig,
// Bind to the chosen workspace so the Loop reads that workspace's memory
// (and groups under it); '' runs on the cross-workspace personal mind.
...(templateWorkspaceId ? { workspaceId: templateWorkspaceId } : {}),
enabled: true,
});
toast({
title: 'Loop created',
description: assistMode
? `${t.name} — proposes actions for your approval`
: `${t.name} — report-only, runs on your machine`,
});
await refresh();
} catch (err) {
toast({ title: 'Failed to create loop', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
} finally {
setCreatingTemplateId(null);
}
};
// L2: approve or reject a held action. Approve runs the real tool server-side
// (idempotent + re-validated); reject marks it denied. Either way it leaves
// the queue.
const decideAction = async (id: string, approved: boolean) => {
setDecidingIds(prev => new Set(prev).add(id));
try {
const r = await adapter.respondApproval(id, approved);
// The row leaves the queue either way (the server row is now terminal), but
// the toast must reflect the REAL outcome — a held action can be approved
// yet refused/failed at execute (route replies 200 {ok:false}).
setPendingActions(prev => prev.filter(p => p.requestId !== id));
if (!approved) toast({ title: 'Action rejected' });
else if (r?.ok === false) toast({ title: 'Action could not run', description: r.error, variant: 'destructive' });
else toast({ title: 'Action approved & run' });
} catch {
toast({ title: 'Failed to update approval', variant: 'destructive' });
} finally {
setDecidingIds(prev => { const next = new Set(prev); next.delete(id); return next; });
}
};
const openLogs = (a: Automation) => { setLogsTarget(a.id); setTab('logs'); };
const startEdit = (a: Automation) => { setEditing(a); setCreating(false); };
const lastLog = (id: string): AutomationLog | null => (logsMap[id]?.[0] ?? null);
const enabledCount = automations.filter(a => a.status === 'active' || a.status === 'running').length;
const failedAutomations = automations.filter(a => { const l = lastLog(a.id); return l !== null && !l.success; });
const allLogs: NamedLog[] = automations
.flatMap(a => (logsMap[a.id] ?? []).map(l => ({ ...l, automationName: a.name })))
.sort((x, y) => Date.parse(y.executedAt) - Date.parse(x.executedAt))
.slice(0, 50);
const overallRate = successRateFromLogs(allLogs);
const logsTargetAutomation = automations.find(a => a.id === logsTarget) ?? null;
const runningList = automations.filter(a => runningIds.has(a.id));
const engineLabel = engine?.running
? `Engine live · on ${engine.host}`
: engine === null ? 'Checking engine…' : 'Engine offline';
const renderRows = (rows: Automation[]) => (
<ul className="space-y-2">
{rows.map(a => (
<AutomationRow
key={a.id}
automation={a}
lastLog={lastLog(a.id)}
runningNow={runningIds.has(a.id)}
onToggle={(x) => void toggle(x)}
onRunNow={(x) => void runNow(x)}
onLogs={openLogs}
onEdit={startEdit}
onDelete={(x) => void remove(x)}
/>
))}
</ul>
);
const labelFor = (id: string | undefined) => workspaceLabel(id, workspaces);
// "Which workspaces": group rows by workspace with a per-group count + success
// rate. With ≤1 group, render the flat list byte-identical to before so
// single-workspace setups (and the existing tests) are unchanged.
const renderGrouped = (rows: Automation[]) => {
const groups = groupAutomationsByWorkspace(rows, labelFor);
if (groups.length <= 1) return renderRows(rows);
return (
<div className="space-y-3" data-testid="automation-workspace-groups">
{groups.map(g => {
const groupLogs = g.automations.flatMap(a => logsMap[a.id] ?? []);
return (
<section key={g.key} role="group" aria-label={g.label}>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[10px] font-display uppercase tracking-wide text-muted-foreground">{g.label}</span>
<span
className="text-[10px] text-muted-foreground tabular-nums"
aria-label={`${g.automations.length} automation${g.automations.length === 1 ? '' : 's'}, ${formatRatePercent(successRateFromLogs(groupLogs))} recent success rate`}
>
{g.automations.length} · {formatRatePercent(successRateFromLogs(groupLogs))}
</span>
</div>
{renderRows(g.automations)}
</section>
);
})}
</div>
);
};
return (
<div className="flex flex-col h-full">
<div className="px-4 py-3 border-b border-border/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-honey" />
<h2 className="text-sm font-display font-semibold text-foreground">Automation Center</h2>
<span className="text-[11px] text-muted-foreground">{automations.length} automation{automations.length === 1 ? '' : 's'}</span>
{/* Loops-engine sovereignty pill: automations only run while this
machine (or your self-hosted server) is live — nothing leaves the
perimeter to a cloud cron. */}
<span
data-testid="automation-engine-pill"
role="status"
aria-live="polite"
aria-label={`${engineLabel}. Loops run locally on your machine — nothing leaves your device.`}
title="Loops run locally on your machine — nothing leaves your device."
className="flex items-center gap-1 rounded-full border border-border/40 bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
<span
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${
engine === null ? 'bg-muted-foreground/40' : engine.running ? 'bg-[var(--healthy)]' : 'bg-[var(--risk)]'
}`}
/>
{engineLabel}
</span>
</div>
<button
onClick={() => { setCreating(true); setEditing(null); }}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-display rounded-lg bg-primary/20 text-honey hover:bg-primary/30 transition-colors"
>
<Plus className="w-3 h-3" /> New
</button>
</div>
{/* §12.10 tab shell. All tabs stay in the Tab order (FilesAppTabs
pattern) — a roving tabIndex without arrow-key handling makes every
inactive tab keyboard-unreachable (WCAG 2.1.1). */}
<div className="px-4 pt-2 flex flex-wrap gap-1" role="tablist" aria-label="Automation Center sections">
{TABS.map(t => (
<button
key={t.id}
id={`automation-tab-${t.id}`}
onClick={() => setTab(t.id)}
role="tab"
aria-selected={tab === t.id}
aria-controls="automation-tab-panel"
className={`px-2 py-0.5 rounded-full text-[11px] transition-colors border ${
tab === t.id ? 'border-primary/40 bg-primary/15 text-honey' : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
{t.id === 'overview' && pendingActions.length > 0 && (
<span className="ml-1 px-1 rounded-full bg-[var(--accent)]/20 text-[var(--accent)] text-[9px] font-semibold" aria-label={`${pendingActions.length} awaiting approval`}>{pendingActions.length}</span>
)}
</button>
))}
</div>
<div id="automation-tab-panel" className="flex-1 overflow-auto p-3 space-y-2" role="tabpanel" aria-labelledby={`automation-tab-${tab}`}>
{loading && automations.length === 0 ? (
<div role="status" aria-live="polite" className="text-center py-8">
<Loader2 className="w-6 h-6 text-muted-foreground/40 mx-auto mb-2 animate-spin" />
<p className="text-xs text-muted-foreground">Loading automations</p>
</div>
) : error && automations.length === 0 ? (
<div role="alert" className="text-center py-8">
<p className="text-xs text-destructive mb-2">{error}</p>
<button onClick={() => refresh()} className="inline-flex items-center gap-1 text-xs text-honey hover:underline">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
) : (
<>
{/* A post-action reload failure must be visible even when a stale
list is still on screen (success-toast-then-silent-rot trap). */}
{error && automations.length > 0 && (
<div role="alert" className="flex items-center justify-between gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-1.5">
<span className="text-[11px] text-destructive">Refresh failed this list may be stale. {error}</span>
<button onClick={() => void refresh()} className="inline-flex items-center gap-1 text-[11px] text-honey hover:underline shrink-0">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
)}
{logsFailedIds.length > 0 && (
<p role="status" className="rounded-lg border border-border/40 bg-muted/30 px-2.5 py-1.5 text-[11px] text-muted-foreground" data-testid="automation-logs-unavailable">
Run history unavailable for {logsFailedIds.length} automation{logsFailedIds.length === 1 ? '' : 's'} status badges and the success rate may be incomplete.
</p>
)}
{tab === 'overview' && (
<>
{pendingActions.length > 0 && (
<div data-testid="automation-pending-actions" className="rounded-lg border border-[var(--accent)]/40 bg-[var(--accent)]/5 px-2.5 py-2 space-y-1.5">
<p className="text-[11px] font-medium text-foreground flex items-center gap-1">
<ShieldAlert className="w-3 h-3 text-[var(--accent)]" /> {pendingActions.length} action{pendingActions.length === 1 ? '' : 's'} awaiting your approval
</p>
{pendingActions.map(p => (
<div key={p.requestId} className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-card/40 px-2 py-1.5">
<span className="min-w-0">
{/* Show the REAL action target from args — never the maker's
self-authored summary as the thing being authorized. */}
<span className="block text-[11px] text-foreground truncate">{describeActionTarget(p.toolName, p.input)}</span>
<span className="block text-[10px] text-muted-foreground truncate">{p.toolName}{p.riskLevel ? ` · ${p.riskLevel} risk` : ''}{p.summary ? ` · ${p.summary}` : ''}</span>
</span>
<span className="flex items-center gap-1 shrink-0">
<button
onClick={() => void decideAction(p.requestId, true)}
disabled={decidingIds.has(p.requestId)}
aria-busy={decidingIds.has(p.requestId)}
aria-label={`Approve ${p.toolName}`}
className="inline-flex items-center gap-0.5 rounded-md bg-[var(--healthy)]/15 text-[var(--healthy)] px-1.5 py-0.5 text-[10px] hover:bg-[var(--healthy)]/25 disabled:opacity-50"
>
<Check className="w-3 h-3" /> Approve
</button>
<button
onClick={() => void decideAction(p.requestId, false)}
disabled={decidingIds.has(p.requestId)}
aria-label={`Reject ${p.toolName}`}
className="inline-flex items-center gap-0.5 rounded-md bg-[var(--risk)]/15 text-[var(--risk)] px-1.5 py-0.5 text-[10px] hover:bg-[var(--risk)]/25 disabled:opacity-50"
>
<X className="w-3 h-3" /> Reject
</button>
</span>
</div>
))}
</div>
)}
<div className="grid grid-cols-3 gap-2" data-testid="automation-overview-tiles">
<div className="rounded-lg bg-secondary/20 border border-border/30 px-2.5 py-2">
<div className="text-lg font-display font-semibold tabular-nums text-foreground">{enabledCount}</div>
<div className="text-[10px] text-muted-foreground leading-tight">Active schedules</div>
</div>
<div className="rounded-lg bg-secondary/20 border border-border/30 px-2.5 py-2">
<div className="text-lg font-display font-semibold tabular-nums text-foreground">{automations.length - enabledCount}</div>
<div className="text-[10px] text-muted-foreground leading-tight">Paused / manual</div>
</div>
<div className="rounded-lg bg-secondary/20 border border-border/30 px-2.5 py-2">
<div className="text-lg font-display font-semibold tabular-nums text-foreground" data-testid="automation-success-rate">{formatRatePercent(overallRate)}</div>
<div className="text-[10px] text-muted-foreground leading-tight">Success rate (recent runs)</div>
</div>
</div>
{failedAutomations.length > 0 && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 space-y-1" data-testid="automation-attention">
<p className="text-[11px] font-medium text-destructive flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Attention required last run failed
</p>
{failedAutomations.map(a => (
<button key={a.id} onClick={() => openLogs(a)} className="block text-left text-[11px] text-foreground hover:text-honey">
{a.name} {lastLog(a.id)?.error ?? 'failed'}
</button>
))}
</div>
)}
{automations.length === 0 && (
<p role="status" className="text-xs text-muted-foreground text-center py-6">No automations yet create one to put background work on a schedule.</p>
)}
{/* The Overview must answer "what runs next, how did the last
runs go" without a tab switch — three stat tiles over a
void was a judge-flagged dead end. */}
{automations.length > 0 && (
<div className="grid sm:grid-cols-2 gap-2" data-testid="automation-overview-panels">
<div className="rounded-lg bg-secondary/20 border border-border/30 px-2.5 py-2">
<p className="text-[10px] font-display uppercase tracking-wide text-muted-foreground mb-1.5">Next up</p>
{(() => {
const upcoming = automations
.filter(a => a.nextRun && (a.status === 'active' || a.status === 'running') && Date.parse(a.nextRun) > Date.now())
.sort((a, b) => Date.parse(a.nextRun as string) - Date.parse(b.nextRun as string))
.slice(0, 3);
return upcoming.length === 0 ? (
<p className="text-[11px] text-muted-foreground">Nothing scheduled.</p>
) : (
<ul className="space-y-1">
{upcoming.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-foreground truncate">{a.name}</span>
<span className="text-muted-foreground shrink-0">{new Date(a.nextRun as string).toLocaleString(DATE_LOCALE)}</span>
</li>
))}
</ul>
);
})()}
</div>
<div className="rounded-lg bg-secondary/20 border border-border/30 px-2.5 py-2">
<p className="text-[10px] font-display uppercase tracking-wide text-muted-foreground mb-1.5">Recent results</p>
{(() => {
const recent = automations
.map(a => ({ a, log: lastLog(a.id) }))
.filter((r): r is { a: typeof r.a; log: NonNullable<typeof r.log> } => r.log !== null)
.sort((x, y) => Date.parse(y.log.executedAt) - Date.parse(x.log.executedAt))
.slice(0, 3);
return recent.length === 0 ? (
<p className="text-[11px] text-muted-foreground">No runs yet.</p>
) : (
<ul className="space-y-1">
{recent.map(({ a, log }) => (
<li key={a.id} className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-foreground truncate">{a.name}</span>
<span className={`shrink-0 ${log.success ? 'text-[var(--healthy)]' : 'text-[var(--risk)]'}`}>
{log.success ? 'OK' : 'failed'} · {new Date(log.executedAt).toLocaleString(DATE_LOCALE)}
</span>
</li>
))}
</ul>
);
})()}
</div>
</div>
)}
<div data-testid="automation-templates" className="rounded-lg border border-border/30 bg-secondary/10 px-2.5 py-2">
<div className="flex items-center justify-between gap-2 mb-1.5 flex-wrap">
<p className="text-[10px] font-display uppercase tracking-wide text-muted-foreground">Start from a template</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
Runs in:
<select
name="automationTemplateWorkspace"
autoComplete="off"
value={templateWorkspaceId}
onChange={(e) => setTemplateWorkspaceId(e.target.value)}
aria-label="Workspace for the new Loop"
data-testid="automation-template-workspace"
className="bg-muted/40 text-[10px] py-0.5 px-1 rounded border border-border/40 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<option value="">All workspaces</option>
{workspaces.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</label>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground" title="The Loop drafts one action per run and holds it for your approval — nothing runs until you approve.">
<input
type="checkbox"
name="automationTemplateAssistMode"
checked={assistMode}
onChange={(e) => setAssistMode(e.target.checked)}
data-testid="automation-template-assist"
className="accent-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
Propose actions for approval
</label>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-1.5">
{LOOP_TEMPLATES.map(t => (
<button
key={t.id}
onClick={() => void createFromTemplate(t)}
disabled={creatingTemplateId !== null}
aria-busy={creatingTemplateId === t.id}
data-testid={`automation-template-${t.id}`}
className="text-left rounded-lg border border-border/40 bg-card/40 px-2 py-1.5 hover:border-primary/40 hover:bg-primary/5 transition-colors disabled:opacity-50"
>
<span className="block text-[11px] font-medium text-foreground">
{t.name}{creatingTemplateId === t.id ? ' · Creating…' : ''}
</span>
<span className="block text-[10px] text-muted-foreground leading-tight">{t.description}</span>
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
{assistMode
? 'Assist Loops summarise from the selected workspaces memory and may propose ONE action per run — held for your approval. Nothing runs until you approve.'
: 'Templates create report-only Loops — they summarise from the selected workspaces memory and notify you; they never act on your behalf.'}
</p>
</div>
</>
)}
{tab === 'running' && (
runningList.length > 0 ? renderGrouped(runningList) : (
<div role="status" className="text-center py-8">
<Clock className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">Nothing running right now.</p>
<p className="text-[11px] text-muted-foreground/70 mt-1">Runs you trigger appear here while in flight; scheduled runs show up in History once they complete.</p>
</div>
)
)}
{tab === 'scheduled' && (
automations.length === 0 ? (
<div role="status" className="text-center py-8">
<Clock className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">No automations yet</p>
</div>
) : renderGrouped(automations)
)}
{tab === 'triggers' && (
<>
<p className="text-[11px] text-muted-foreground">Schedule-only in v1 event triggers arrive in a later release.</p>
{automations.length === 0 ? (
<p role="status" className="text-xs text-muted-foreground text-center py-6">No triggers configured.</p>
) : (
<ul className="space-y-1">
{automations.map(a => (
<li key={a.id} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-card/40 px-2.5 py-1.5">
<span className="flex-1 min-w-0">
<span className="block text-xs font-medium text-foreground truncate">{a.name}</span>
<span className="block text-[10px] text-muted-foreground" title={a.triggerType === 'manual' ? undefined : (a.schedule ?? undefined)}>{describeTrigger(a)}</span>
</span>
{a.nextRun && (a.status === 'active' || a.status === 'running') && (
<span className="text-[10px] text-muted-foreground shrink-0">Next: {new Date(a.nextRun).toLocaleString(DATE_LOCALE)}</span>
)}
</li>
))}
</ul>
)}
</>
)}
{tab === 'history' && (
<AutomationLogList logs={allLogs} emptyText="No runs recorded yet — history appears after the first execution." />
)}
{tab === 'logs' && (
<>
<div className="flex flex-wrap gap-1">
{automations.map(a => (
<button
key={a.id}
onClick={() => setLogsTarget(a.id)}
aria-pressed={logsTarget === a.id}
className={`px-2 py-0.5 rounded-full text-[11px] transition-colors border ${
logsTarget === a.id ? 'border-primary/40 bg-primary/15 text-honey' : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground'
}`}
>
{a.name}
</button>
))}
</div>
{logsTargetAutomation ? (
<>
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-muted-foreground">
Success rate: <span className="text-foreground font-medium">{formatRatePercent(successRateFromLogs(logsMap[logsTargetAutomation.id] ?? []))}</span>
</p>
<button
onClick={() => void runNow(logsTargetAutomation)}
disabled={runningIds.has(logsTargetAutomation.id)}
className="text-[11px] text-honey hover:underline disabled:opacity-50"
>
Retry / run now
</button>
</div>
<AutomationLogList logs={logsMap[logsTargetAutomation.id] ?? []} emptyText="No runs recorded for this automation yet." />
</>
) : (
<p role="status" className="text-xs text-muted-foreground text-center py-6">
{automations.length === 0 ? 'No automations yet.' : 'Pick an automation to inspect its run log.'}
</p>
)}
</>
)}
</>
)}
</div>
{/* S20 Automation Builder (Phase 3C) — body-portaled modal stepper for
create AND edit (the 3B inline form merged into it). */}
{(creating || editing) && (
<AutomationBuilder
key={editing?.id ?? 'create'}
initial={editing ?? undefined}
busy={saving}
onSubmit={(d) => void submit(d)}
onCancel={() => { setCreating(false); setEditing(null); }}
/>
)}
<ApprovalModal
request={deleteApproval}
approveLabel={deletingId ? 'Deleting...' : 'Delete automation'}
busy={deletingId !== null}
onApprove={() => { void confirmDelete(); }}
onCancel={() => {
if (deletingId === null) setPendingDelete(null);
}}
/>
</div>
);
};
export default AutomationCenterApp;

View File

@@ -0,0 +1,226 @@
import { useState, useEffect, type ChangeEvent } from 'react';
import { Archive, Download, Upload, Loader2, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
interface BackupMeta {
timestamp: string;
workspaces: number;
frames: number;
sizeBytes: number;
}
/**
* R4-006 — classify a `GET /api/backup/metadata` response status.
*
* 404 is the legitimate "no backups yet" empty state (the route 404s when no
* metadata file exists). Any other non-2xx is a real fault that must surface
* as a retryable error — NOT be collapsed into the empty state. Exported pure
* so the rule is regression-testable without rendering React (same constraint
* as ConnectorsApp.shouldResetCredentialInputs).
*/
export function classifyMetadataStatus(status: number): 'ok' | 'empty' | 'error' {
if (status === 404) return 'empty';
if (status >= 200 && status < 300) return 'ok';
return 'error';
}
const BackupApp = () => {
const [backups, setBackups] = useState<BackupMeta[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [creating, setCreating] = useState(false);
const [restoring, setRestoring] = useState(false);
const [lastResult, setLastResult] = useState<string | null>(null);
const [pendingRestoreFile, setPendingRestoreFile] = useState<File | null>(null);
const loadMetadata = () => {
setLoading(true);
setLoadError(false);
// The route returns 404 when no backup exists yet — that's the legitimate
// empty state, not a failure. Any other non-2xx (or a network error) is a
// real fault and must surface as a retryable error, never as "No backups".
adapter.fetchRaw('/api/backup/metadata')
.then(async r => {
const kind = classifyMetadataStatus(r.status);
if (kind === 'empty') { setBackups([]); setLoading(false); return; }
if (kind === 'error') { setLoadError(true); setLoading(false); return; }
const data = await r.json();
setBackups(Array.isArray(data) ? data : data.backups ?? []);
setLoading(false);
})
.catch(() => { setLoadError(true); setLoading(false); });
};
useEffect(() => { loadMetadata(); }, []);
const handleBackup = async () => {
setCreating(true);
setLastResult(null);
try {
const res = await adapter.fetchRaw('/api/backup', { method: 'POST' });
if (res.ok) {
setLastResult('Backup created successfully.');
const data = await res.json().catch(() => null);
if (data) setBackups(prev => [data, ...prev]);
} else {
setLastResult('Backup failed. Check server logs.');
}
} catch {
setLastResult('Connection error — is the backend running?');
}
setCreating(false);
};
const restoreBackup = async (file: File) => {
setRestoring(true);
setLastResult(null);
try {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
reader.onerror = () => reject(reader.error ?? new Error('read failed'));
reader.readAsDataURL(file);
});
const res = await adapter.fetchRaw('/api/restore', {
method: 'POST',
body: JSON.stringify({ backup: base64 }),
});
if (res.ok) {
const data = await res.json().catch(() => null);
const count = data?.filesRestored;
setLastResult(
typeof count === 'number'
? `Backup restored successfully (${count} files). Restart the server to apply.`
: 'Backup restored successfully. Restart the server to apply.',
);
} else {
const err = await res.json().catch(() => null);
setLastResult(err?.error ?? 'Restore failed. Check server logs.');
}
} catch {
setLastResult('Connection error — is the backend running?');
}
setRestoring(false);
};
const handleRestore = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so re-selecting the same file fires onChange again.
e.target.value = '';
if (!file) return;
setLastResult(null);
setPendingRestoreFile(file);
};
const restoreApproval: ApprovalRequest | null = pendingRestoreFile
? {
action: `Restore backup: ${pendingRestoreFile.name}`,
riskLevel: 'critical',
scope: [
'Overwrite current data with the selected backup.',
'Current workspaces, sessions, and memory may be replaced.',
'A restart is required after restore succeeds.',
],
}
: null;
const confirmRestore = async () => {
if (!pendingRestoreFile) return;
const file = pendingRestoreFile;
await restoreBackup(file);
setPendingRestoreFile(null);
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0 px-4 py-3 border-b border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Archive className="w-4 h-4 text-honey" />
<h2 className="text-sm font-display font-semibold text-foreground">Backup & Restore</h2>
</div>
<div className="flex items-center gap-2">
<button onClick={handleBackup} disabled={creating || restoring}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-primary/20 text-honey hover:bg-primary/30 transition-colors disabled:opacity-50 font-display">
{creating ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
{creating ? 'Creating...' : 'Create Backup'}
</button>
<label className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-secondary/50 text-foreground hover:bg-secondary/70 transition-colors font-display cursor-pointer ${creating || restoring ? 'opacity-50 pointer-events-none' : ''}`}>
{restoring ? <Loader2 className="w-3 h-3 animate-spin" /> : <Upload className="w-3 h-3" />}
{restoring ? 'Restoring...' : 'Restore'}
<input type="file" accept=".waggle-backup" aria-label="Restore backup file" className="sr-only" disabled={creating || restoring} onChange={handleRestore} />
</label>
</div>
</div>
{lastResult && (
<p className={`text-[11px] mt-2 ${lastResult.includes('success') ? 'text-emerald-400' : 'text-destructive'}`}>
{lastResult}
</p>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-5 h-5 animate-spin text-honey" />
</div>
) : loadError ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<AlertTriangle className="w-8 h-8 text-destructive/40 mb-2" />
<p className="text-sm text-foreground">Couldn't load backup history.</p>
<p className="text-xs text-muted-foreground mt-1">The backend may be offline or returned an error.</p>
<button onClick={loadMetadata}
className="mt-3 px-3 py-1.5 text-xs rounded-lg bg-primary/20 text-honey hover:bg-primary/30 transition-colors font-display">
Retry
</button>
</div>
) : backups.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<Archive className="w-8 h-8 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">No backups yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create your first backup to protect your workspaces and memories.</p>
</div>
) : (
<div className="space-y-2">
{backups.map((b, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted/20 hover:bg-muted/30 transition-colors">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground font-display">
{new Date(b.timestamp).toLocaleString(DATE_LOCALE)}
</p>
<p className="text-[11px] text-muted-foreground">
{b.workspaces} workspace{b.workspaces !== 1 ? 's' : ''} · {b.frames} frames · {formatSize(b.sizeBytes)}
</p>
</div>
<label className={`text-[11px] text-honey hover:text-honey/80 font-display cursor-pointer ${creating || restoring ? 'opacity-50 pointer-events-none' : ''}`}>
Restore
<input type="file" accept=".waggle-backup" aria-label={`Restore backup file from ${new Date(b.timestamp).toLocaleString(DATE_LOCALE)}`} className="sr-only" disabled={creating || restoring} onChange={handleRestore} />
</label>
</div>
))}
</div>
)}
</div>
<ApprovalModal
request={restoreApproval}
approveLabel={restoring ? 'Restoring...' : 'Restore backup'}
busy={restoring}
onApprove={() => { void confirmRestore(); }}
onCancel={() => {
if (!restoring) setPendingRestoreFile(null);
}}
/>
</div>
);
};
export default BackupApp;

View File

@@ -0,0 +1,76 @@
/**
* BenchmarkApp — screen 17. Pure-UI static showcase. The test renders the
* component, asserts the Capabilities view's framing + matrix, toggles to the
* Memory SOTA view, and asserts the LoCoMo numbers + stat chips. No mocks
* needed — the component has zero props and makes no calls.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import BenchmarkApp from './BenchmarkApp';
afterEach(() => cleanup());
describe('BenchmarkApp', () => {
it('renders the Capabilities view by default with the positioning framing', () => {
render(<BenchmarkApp />);
// Tablist toggle present, Capabilities tab selected.
expect(screen.getByRole('tab', { name: /vs competitors/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /memory sota/i })).toHaveAttribute('aria-selected', 'false');
// Headline + the two framing cards.
expect(screen.getByText(/waggle remembers you/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /task agents/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /a memory layer \+ workspace/i })).toBeInTheDocument();
// Honest disclaimer label.
expect(screen.getByText(/positioning view, not a lab benchmark/i)).toBeInTheDocument();
});
it('renders the full 11-row capability matrix with the deliberate competitor win on deep coding', () => {
render(<BenchmarkApp />);
// Waggle column header + first/last capability rows.
expect(screen.getByRole('columnheader', { name: /^waggle$/i })).toBeInTheDocument();
expect(screen.getByRole('rowheader', { name: /persistent memory across sessions/i })).toBeInTheDocument();
const lastRow = screen.getByRole('rowheader', { name: /deep coding in the terminal/i });
expect(lastRow).toBeInTheDocument();
// 11 capability rows = 11 row headers.
expect(screen.getAllByRole('rowheader')).toHaveLength(11);
// The last row is honest: Waggle is only Partial on deep terminal coding.
const row = lastRow.closest('tr');
expect(row).not.toBeNull();
const cells = within(row as HTMLElement).getAllByRole('cell');
expect(cells[0]).toHaveAttribute('aria-label', 'Partial'); // Waggle column
expect(cells[1]).toHaveAttribute('aria-label', 'Yes'); // Claude Code column
});
it('toggles to the Memory SOTA view and shows the LoCoMo numbers + stat chips', () => {
render(<BenchmarkApp />);
fireEvent.click(screen.getByRole('tab', { name: /memory sota/i }));
// View switched.
expect(screen.getByRole('tab', { name: /memory sota/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.queryByRole('rowheader', { name: /persistent memory across sessions/i })).not.toBeInTheDocument();
// SOTA headline + the four LoCoMo bar values.
expect(screen.getByText(/best long-term memory/i)).toBeInTheDocument();
expect(screen.getByText('86.49')).toBeInTheDocument();
expect(screen.getByText('81.95')).toBeInTheDocument();
expect(screen.getByText('78.05')).toBeInTheDocument();
expect(screen.getByText('62.47')).toBeInTheDocument();
// Stat chips.
expect(screen.getByText('+4.54')).toBeInTheDocument();
expect(screen.getByText('92.27%')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
// Method/caveat line.
expect(screen.getByText(/github\.com\/marolinik\/hive-mind/i)).toBeInTheDocument();
});
it('toggles back to Capabilities', () => {
render(<BenchmarkApp />);
fireEvent.click(screen.getByRole('tab', { name: /memory sota/i }));
fireEvent.click(screen.getByRole('tab', { name: /vs competitors/i }));
expect(screen.getByRole('tab', { name: /vs competitors/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('rowheader', { name: /persistent memory across sessions/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,411 @@
/**
* BenchmarkApp — Warm-Hive Benchmarks surface (screen 17). A pure-UI, ⌘K-only
* showcase with two views behind a segmented toggle:
*
* 1) Capabilities — honest positioning vs the field (task agents vs Waggle's
* memory layer), an 11-row capability matrix with the Waggle column
* honey-highlighted. Deliberately credits competitors on "deep terminal
* coding" — it's a positioning view, not a lab benchmark.
* 2) Memory SOTA — the one real head-to-head lab result: LoCoMo bars + stats.
*
* Static data only — no backend, no API, no props (default-export, zero-prop).
* Source fidelity: docs/design_handoff_waggle_app/design-files/screens/benchmark.html
*/
import { useState } from 'react';
import { BarChart3, Info } from 'lucide-react';
// ───────────────────────────────────────────────────────────────────────────
// Benchmark figures — manually refreshed from the hive-mind benchmark,
// 2026-06-18 — do not fabricate. Numbers MUST match benchmark.html exactly.
// ───────────────────────────────────────────────────────────────────────────
/** A capability mark: yes (●) / partial (◐) / no (○). */
type Mark = 'Y' | 'P' | 'N';
/** Competitor columns, in matrix order after the honey Waggle column. */
const COMPETITORS = [
{ name: 'Claude Code', sub: 'CLI' },
{ name: 'Codex', sub: 'CLI' },
{ name: 'Cowork', sub: 'Anthropic' },
{ name: 'Hermes', sub: 'agent' },
{ name: 'Odysseus', sub: 'agent' },
] as const;
/** 11-row capability matrix. marks order: Waggle, then the 5 COMPETITORS. */
const CAPABILITY_MATRIX: ReadonlyArray<{ capability: string; marks: readonly Mark[] }> = [
{ capability: 'Persistent memory across sessions', marks: ['Y', 'P', 'N', 'P', 'P', 'P'] },
{ capability: 'Local-first & private by default', marks: ['Y', 'N', 'N', 'N', 'P', 'P'] },
{ capability: 'Any model — incl. small local models', marks: ['Y', 'N', 'N', 'N', 'P', 'P'] },
{ capability: 'Built for non-technical experts', marks: ['Y', 'N', 'N', 'P', 'N', 'N'] },
{ capability: 'Runs your existing coding agents', marks: ['Y', 'N', 'N', 'N', 'N', 'N'] },
{ capability: 'Self-evolving skills', marks: ['Y', 'N', 'N', 'N', 'P', 'N'] },
{ capability: 'Skills shared across agents/workspaces', marks: ['Y', 'P', 'P', 'N', 'P', 'N'] },
{ capability: 'Multi-agent swarm', marks: ['Y', 'P', 'N', 'P', 'P', 'Y'] },
{ capability: 'Native desktop app · Win + Mac', marks: ['Y', 'N', 'N', 'P', 'N', 'N'] },
{ capability: 'Audit-ready / EU AI Act', marks: ['Y', 'P', 'P', 'P', 'N', 'N'] },
{ capability: 'Deep coding in the terminal', marks: ['P', 'Y', 'Y', 'P', 'P', 'Y'] },
];
/** LoCoMo memory bars — value drives both the label and the bar width. */
const MEMORY_BARS: ReadonlyArray<{ name: string; note: string; value: number; us?: boolean }> = [
{ name: 'Waggle · Hive Mind', note: 'ours · local', value: 86.49, us: true },
{ name: 'Memori', note: 'prev. SOTA', value: 81.95 },
{ name: 'LangMem', note: 'corrected', value: 78.05 },
{ name: 'Mem0', note: 'baseline', value: 62.47 },
];
/** Stat chips beneath the bars. */
const MEMORY_STATS: ReadonlyArray<{ value: string; label: React.ReactNode }> = [
{ value: '+4.54', label: <>points over the prior best · <b className="text-foreground font-semibold">z = 4.64, p &lt; 10</b></> },
{ value: '92.27%', label: <>single-hop recall <b className="text-foreground font-semibold">~1pt off the full-context ceiling</b></> },
{ value: '100%', label: <>local warm recalls in <b className="text-foreground font-semibold">5883 ms</b>, on-device</> },
];
// ───────────────────────────────────────────────────────────────────────────
type View = 'caps' | 'memory';
const VIEW_LABELS: Record<View, React.ReactNode> = {
caps: <><b className="text-foreground font-semibold">Capabilities</b> where Waggle sits vs the field</>,
memory: <><b className="text-foreground font-semibold">Memory SOTA</b> the one head-to-head lab result</>,
};
const MARK_GLYPH: Record<Mark, string> = { Y: '●', P: '◐', N: '○' };
/** Colour a mark by kind. The Waggle (first) column reads honey. */
function markClass(mark: Mark, isUs: boolean): string {
if (isUs && mark === 'Y') return 'font-bold';
if (mark === 'Y') return '';
if (mark === 'P') return '';
return 'opacity-60';
}
function markStyle(mark: Mark, isUs: boolean): React.CSSProperties {
if (isUs) return { color: 'var(--honey)' };
if (mark === 'Y') return { color: 'var(--healthy)' };
if (mark === 'P') return { color: 'var(--attention)' };
return { color: 'var(--text-2)' };
}
/** One disclaimer/caveat block — shared between the two views. */
function Disclaimer({ children }: { children: React.ReactNode }) {
return (
<div
className="mt-4 flex items-start gap-3 rounded-xl px-4 py-3.5"
style={{ background: 'var(--bg-2)', border: '1px solid var(--line-soft)' }}
>
<Info className="w-4 h-4 mt-0.5 shrink-0" style={{ color: 'var(--text-muted, hsl(var(--muted-foreground)))' }} />
<p className="text-xs leading-relaxed text-muted-foreground m-0">{children}</p>
</div>
);
}
const BenchmarkApp = () => {
const [view, setView] = useState<View>('caps');
return (
<div className="flex flex-col h-full">
{/* Controls bar — segmented toggle + live view label */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-border/40 shrink-0">
<BarChart3 className="w-5 h-5" style={{ color: 'var(--honey)' }} />
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-muted-foreground hidden sm:inline">
Benchmarks · view
</span>
<div
role="tablist"
aria-label="Benchmark view"
className="inline-flex gap-1 p-[3px] rounded-[10px]"
style={{ background: 'var(--secondary, hsl(var(--secondary)))', border: '1px solid var(--line-soft)' }}
>
{(['caps', 'memory'] as View[]).map(v => (
<button
key={v}
role="tab"
aria-selected={view === v}
onClick={() => setView(v)}
className={`text-[12.5px] font-display font-semibold px-3 py-1.5 rounded-md transition-colors whitespace-nowrap ${
view === v ? '' : 'text-muted-foreground hover:text-foreground'
}`}
style={view === v ? { background: 'var(--honey)', color: '#1a1407' } : undefined}
>
{v === 'caps' ? 'vs competitors' : 'Memory SOTA'}
</button>
))}
</div>
<span className="text-[12.5px] text-muted-foreground ml-1 hidden md:inline">{VIEW_LABELS[view]}</span>
</div>
{/* Stage */}
<div className="flex-1 min-h-0 overflow-auto" tabIndex={0} role="region" aria-label="Benchmark content">
<div className="max-w-[1000px] mx-auto px-8 py-7 pb-16">
{view === 'caps' ? <CapabilitiesView /> : <MemorySotaView />}
</div>
</div>
</div>
);
};
function CapabilitiesView() {
return (
<div role="tabpanel" aria-label="Capabilities">
<header className="mb-6">
<div
className="font-mono text-[11px] uppercase tracking-[0.14em] mb-3 flex items-center gap-2.5"
style={{ color: 'var(--honey)' }}
>
<span className="inline-block w-5 h-px" style={{ background: 'var(--honey-line)' }} aria-hidden="true" />
How we&apos;re different
</div>
<h1 className="text-[28px] font-display font-semibold tracking-tight leading-tight m-0 mb-2.5 text-foreground">
They automate tasks. <em className="not-italic" style={{ color: 'var(--honey)' }}>Waggle remembers you.</em>
</h1>
<p className="text-[15px] text-muted-foreground leading-relaxed m-0 max-w-[64ch]">
The strong agents today are <b className="text-foreground font-medium">terminal coding tools</b> for engineers.
Waggle plays a different game: a <b className="text-foreground font-medium">persistent, local-first memory layer</b> for
knowledge workers that even <b className="text-foreground font-medium">runs those agents inside it</b>. Here&apos;s the
honest lay of the land.
</p>
</header>
{/* Two framing cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-7">
<article
className="p-5 rounded-2xl"
style={{ background: 'var(--card, hsl(var(--card)))', border: '1px solid var(--line-soft)' }}
>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] mb-2.5 text-muted-foreground">The field</div>
<h2 className="text-lg font-display font-semibold tracking-tight m-0 mb-2 text-foreground">Task agents</h2>
<p className="m-0 text-[13px] text-muted-foreground leading-relaxed">
Powerful, mostly terminal-based, model-locked, and built for developers. Each session starts fresh; the
intelligence lives in the model, not in a memory of you.
</p>
<div className="mt-3 text-[11.5px] text-muted-foreground/80">
<b className="text-foreground/90 font-medium">Claude Code · Codex · Claude Cowork · Hermes · Odysseus</b>
</div>
</article>
<article
className="p-5 rounded-2xl"
style={{
border: '1px solid var(--honey-line)',
background: 'linear-gradient(155deg, var(--secondary, hsl(var(--secondary))), var(--card, hsl(var(--card))))',
}}
>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] mb-2.5" style={{ color: 'var(--honey)' }}>
Our category
</div>
<h2 className="text-lg font-display font-semibold tracking-tight m-0 mb-2 text-foreground">
A memory layer + workspace
</h2>
<p className="m-0 text-[13px] text-muted-foreground leading-relaxed">
Knows you and your work across every session, runs on any model (even local), is built for non-technical
experts and can launch the task agents into its shared memory.
</p>
<div className="mt-3 text-[11.5px] text-muted-foreground/80">
<b className="text-foreground/90 font-medium">Waggle</b> complements them, doesn&apos;t compete head-on
</div>
</article>
</div>
{/* Capability matrix */}
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--card, hsl(var(--card)))', border: '1px solid var(--line-soft)' }}
>
<table className="w-full border-collapse">
<caption className="sr-only">Capability comparison: Waggle versus task agents</caption>
<thead>
<tr>
<th
scope="col"
className="text-left text-[13px] font-semibold p-3 w-[32%] text-foreground"
style={{ borderBottom: '1px solid var(--line-soft)', background: 'var(--secondary, hsl(var(--secondary)))' }}
>
Capability
</th>
<th
scope="col"
className="text-center text-[11.5px] font-semibold p-3"
style={{
color: 'var(--honey)',
borderBottom: '1px solid var(--line-soft)',
background: 'color-mix(in srgb, var(--honey) 7%, transparent)',
}}
>
Waggle
</th>
{COMPETITORS.map(c => (
<th
key={c.name}
scope="col"
className="text-center text-[11.5px] font-semibold p-3 text-foreground/90"
style={{ borderBottom: '1px solid var(--line-soft)', background: 'var(--secondary, hsl(var(--secondary)))' }}
>
<span className="whitespace-nowrap">{c.name}</span>
<span className="block font-mono font-normal text-[9.5px] mt-0.5 text-muted-foreground/70">{c.sub}</span>
</th>
))}
</tr>
</thead>
<tbody>
{CAPABILITY_MATRIX.map((row, rowIdx) => {
const isLast = rowIdx === CAPABILITY_MATRIX.length - 1;
return (
<tr key={row.capability}>
<th
scope="row"
className="text-left text-[13px] font-normal p-3 w-[32%]"
style={{ borderBottom: '1px solid var(--line-soft)', color: 'var(--text-2)' }}
>
{row.capability}
</th>
{row.marks.map((mark, colIdx) => {
const isUs = colIdx === 0;
return (
<td
key={colIdx}
className="text-center p-3"
aria-label={mark === 'Y' ? 'Yes' : mark === 'P' ? 'Partial' : 'No'}
style={{
borderBottom: isUs && isLast ? '1px solid var(--honey-line)' : '1px solid var(--line-soft)',
...(isUs
? {
background: 'color-mix(in srgb, var(--honey) 7%, transparent)',
borderLeft: '1px solid var(--honey-line)',
borderRight: '1px solid var(--honey-line)',
}
: {}),
}}
>
<span
className={`inline-grid place-items-center w-[22px] h-[22px] text-[13px] ${markClass(mark, isUs)}`}
style={markStyle(mark, isUs)}
>
{MARK_GLYPH[mark]}
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 mt-4 text-[12.5px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span style={{ color: 'var(--healthy)' }}></span>Yes
</span>
<span className="inline-flex items-center gap-1.5">
<span style={{ color: 'var(--attention)' }}></span>Partial / limited
</span>
<span className="inline-flex items-center gap-1.5">
<span style={{ color: 'var(--text-2)' }}></span>No / not the focus
</span>
</div>
<Disclaimer>
<b className="text-foreground/90 font-medium">This is a positioning view, not a lab benchmark</b> capabilities as
understood June 2026, focused on the dimensions Waggle is built around. Coding agents are genuinely excellent at
deep code work (the last row), which is exactly why Waggle <b className="text-foreground/90 font-medium">launches them</b>{' '}
rather than replacing them. The one head-to-head lab result is the memory benchmark see &ldquo;Memory SOTA&rdquo;.
</Disclaimer>
</div>
);
}
function MemorySotaView() {
const maxValue = Math.max(...MEMORY_BARS.map(b => b.value));
return (
<div role="tabpanel" aria-label="Memory SOTA">
<header className="mb-6">
<div
className="font-mono text-[11px] uppercase tracking-[0.14em] mb-3 flex items-center gap-2.5"
style={{ color: 'var(--honey)' }}
>
<span className="inline-block w-5 h-px" style={{ background: 'var(--honey-line)' }} aria-hidden="true" />
State of the art · LoCoMo · June 2026
</div>
<h1 className="text-[28px] font-display font-semibold tracking-tight leading-tight m-0 mb-2.5 text-foreground">
The best long-term memory <em className="not-italic" style={{ color: 'var(--honey)' }}>on record.</em>
</h1>
<p className="text-[15px] text-muted-foreground leading-relaxed m-0 max-w-[64ch]">
On <b className="text-foreground font-medium">LoCoMo</b> the standard test for long-term conversational memory
Waggle&apos;s open-source substrate scores <b className="text-foreground font-medium">86.49%</b>, a new state of the
art, measured under the prior leader&apos;s own protocol and judge.
</p>
</header>
{/* LoCoMo bars */}
<div className="grid gap-3 mt-2">
{MEMORY_BARS.map(bar => (
<div
key={bar.name}
className="grid items-center gap-4"
style={{ gridTemplateColumns: '180px 1fr 64px' }}
>
<div className="text-[13.5px] font-semibold text-right leading-tight text-foreground">
{bar.name}
<small className="block font-normal font-mono text-[10.5px] mt-0.5 text-muted-foreground/70">{bar.note}</small>
</div>
<div
className="h-[34px] rounded-[9px] overflow-hidden"
role="img"
aria-label={`${bar.name}: ${bar.value} percent`}
style={{ background: 'var(--card, hsl(var(--card)))', border: '1px solid var(--line-soft)' }}
>
<div
className="h-full rounded-l-lg"
style={{
width: `${(bar.value / maxValue) * 100}%`,
...(bar.us
? {
background: 'linear-gradient(90deg, var(--honey-deep), var(--honey-bright))',
boxShadow: 'var(--shadow-honey)',
}
: { background: 'var(--muted, hsl(var(--muted)))' }),
}}
/>
</div>
<div
className="font-mono text-sm font-semibold"
style={{ color: bar.us ? 'var(--honey)' : 'var(--text-2)' }}
>
{bar.value}
</div>
</div>
))}
</div>
{/* Stat chips */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5 my-6">
{MEMORY_STATS.map((stat, i) => (
<div
key={i}
className="p-5 rounded-2xl"
style={{ background: 'var(--card, hsl(var(--card)))', border: '1px solid var(--line-soft)' }}
>
<div className="text-[32px] font-display font-extrabold tracking-tight" style={{ color: 'var(--honey)' }}>
{stat.value}
</div>
<div className="text-[13px] text-muted-foreground mt-1.5">{stat.label}</div>
</div>
))}
</div>
<Disclaimer>
<b className="text-foreground/90 font-medium">LoCoMo</b>, N = 1,540, GPT-4.1-mini as answerer &amp; judge the prior
SOTA&apos;s exact published protocol, reproduced in-harness to 0.03 points before comparison. The intelligence lives in
the <b className="text-foreground/90 font-medium">memory layer, not the model</b>, so it travels onto a small local
model too. Honest caveat: higher token use per question than the leanest systems; efficiency work underway.
Reproducible offline:{' '}
<span className="font-mono" style={{ color: 'var(--text-2)' }}>github.com/marolinik/hive-mind</span>.
</Disclaimer>
</div>
);
}
export default BenchmarkApp;

View File

@@ -0,0 +1,666 @@
import { useState, useEffect, useCallback } from 'react';
import { Package, Download, CheckCircle2, Shield, Star, Search, Loader2, Store, Grid3X3, List, FlaskConical, X, Plus, FileCode2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { adapter } from '@/lib/adapter';
import { useService } from '@/providers/ServiceProvider';
import type { SkillPack, Skill } from '@/lib/types';
import {
describeTrust,
summariseSkills,
} from '@/lib/skill-pack-display';
import { dedupePacks } from '@/lib/dedupe-packs';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import SkillRow from './skills/SkillRow';
import SkillEditorDrawer from './skills/SkillEditorDrawer';
import SkillBuilder from './skills/SkillBuilder';
import InstallAuditPanel from './extend/InstallAuditPanel';
/**
* Skills Hub (UX-Refactor Phase 3B, S06). Browse / install / author / test
* reusable skills. PRD tab vocabulary: My Skills · Marketplace · Custom ·
* Workspace, with Packs / Tools / Audit kept as secondary panels. Acceptance:
* a user can understand what a skill does and what access it has.
* - Test = C37 PREVIEW-ONLY (injected prompt + parsed metadata, no LLM call).
* - Install = the per-source dispatcher (starter | pack | marketplace) —
* capability packs no longer mis-route through the starter installer.
* - The PRO tier gate stays load-bearing: 403 → 'waggle:tier-insufficient'
* → UpgradeModal.
*/
const trustBadges: Record<string, { color: string; icon: React.ElementType }> = {
verified: { color: 'text-emerald-400', icon: CheckCircle2 },
community: { color: 'text-sky-400', icon: Star },
experimental: { color: 'text-amber-400', icon: Shield },
};
const categoryColors: Record<string, string> = {
research: 'bg-violet-500/20 text-violet-400',
writing: 'bg-amber-500/20 text-amber-400',
planning: 'bg-sky-500/20 text-sky-400',
team: 'bg-emerald-500/20 text-emerald-400',
decision: 'bg-rose-500/20 text-rose-400',
};
/** Pack tagged with which installer owns it (per-source dispatcher). */
type CatalogPack = SkillPack & { installSource?: 'starter' | 'pack' };
type HubTab = 'my-skills' | 'marketplace' | 'custom' | 'workspace' | 'starter' | 'tools' | 'audit';
const TAB_LABELS: Record<HubTab, string> = {
'my-skills': 'My Skills',
marketplace: 'Marketplace',
custom: 'Custom',
workspace: 'Workspace',
starter: 'Packs',
tools: 'Tools',
audit: 'Audit',
};
const TAB_HINTS: Record<HubTab, string> = {
'my-skills': 'Every skill installed on this machine — test or edit any of them',
marketplace: 'The Marketplace is now ONE consolidated surface in the Extend zone — this tab points there.',
custom: 'Skills you authored locally (not from any catalog)',
workspace: 'Workspace-scoped skills (scope metadata lands with the backend ?scope= filter)',
starter: 'Curated skill packs that ship with Waggle — starter packs + capability packs',
tools: 'Low-level tools agents can call (read_file, run_command, etc.). Not the same as skills.',
audit: 'Install / uninstall history — who added what and when',
};
const CapabilitiesApp = () => {
// Cold-load race guard (same fix as HomeCockpit): wait for the adapter's
// initial connect() to settle so a restored window doesn't fire authed
// catalog calls before the session token exists.
const { connecting } = useService();
const [packs, setPacks] = useState<CatalogPack[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [installing, setInstalling] = useState<string | null>(null);
const [installError, setInstallError] = useState<string | null>(null);
const [tab, setTab] = useState<HubTab>('my-skills');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [testResult, setTestResult] = useState<{ name: string; preview: string; metadata?: Record<string, unknown> } | null>(null);
const [testing, setTesting] = useState<string | null>(null);
/** §D2 — name of the skill currently being run-and-graded by the audit loop. */
const [verifying, setVerifying] = useState<string | null>(null);
/** M-45 / P29 — pack detail drawer target. Null when closed. */
const [selectedPack, setSelectedPack] = useState<SkillPack | null>(null);
const [editingSkill, setEditingSkill] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
/** C37 preview-only test via the 3A :id route — no execution, no LLM call. */
const handleTestSkill = async (skillName: string) => {
setTesting(skillName);
try {
const res = await adapter.testSkill(skillName);
setTestResult({ name: skillName, preview: res.wouldInject ?? 'No preview available', metadata: res.skill });
} catch (err) {
// Surface the failure in the preview panel — a silent stop on a
// first-class row action reads as a dead button.
const message = err instanceof Error ? err.message : 'server unreachable';
setTestResult({ name: skillName, preview: `Preview unavailable — ${message}` });
}
finally { setTesting(null); }
};
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.allSettled([
adapter.getSkills(),
adapter.getStarterPacks(),
adapter.getMarketplacePacks(),
adapter.getCapabilityPacks(),
])
.then(([skillsRes, starters, marketplace, caps]) => {
// Build set of installed skill names + every catalog-known skill name
// (catalog ids AND their bundled skill lists) so user-authored skills
// can be told apart (Custom tab). Marketplace packs get their own set
// so marketplace-installed skills classify as 'marketplace', never as
// "skills you authored locally".
const installedNames = new Set<string>();
const catalogNames = new Set<string>();
const marketplaceNames = new Set<string>();
if (skillsRes.status === 'fulfilled') {
skillsRes.value.forEach(s => installedNames.add(s.id || s.name));
}
// Mark starters and caps as installed if they match, tagging each pack
// with the installer that owns it (starter-pack vs capability-pack).
const all: CatalogPack[] = [];
if (starters.status === 'fulfilled') {
starters.value.forEach(s => {
catalogNames.add(s.id || s.name);
(s.skills ?? []).forEach(n => catalogNames.add(n));
all.push({ ...s, id: s.id || s.name, installed: installedNames.has(s.id || s.name) || s.installed, installSource: 'starter' });
});
}
if (caps.status === 'fulfilled') {
caps.value.forEach(s => {
catalogNames.add(s.id || s.name);
(s.skills ?? []).forEach(n => catalogNames.add(n));
all.push({ ...s, id: s.id || s.name, installed: installedNames.has(s.id || s.name) || s.installed, installSource: 'pack' });
});
}
// L-17 C4 — de-dup by id||name (the catalogs can overlap).
setPacks(dedupePacks(all) as CatalogPack[]);
if (marketplace.status === 'fulfilled') {
// Phase 4B: the marketplace pack GRID moved to the consolidated S21
// surface; the catalog stays load-bearing here for classification.
marketplace.value.forEach(p => {
marketplaceNames.add(p.id || p.name);
(p.skills ?? []).forEach(n => marketplaceNames.add(n));
});
}
// 'custom' is a DERIVED claim ("not in any catalog") — it is only safe
// when every catalog actually loaded. On a partial catalog failure
// degrade to 'installed' instead of mislabeling everything custom.
const catalogsKnown = starters.status === 'fulfilled'
&& caps.status === 'fulfilled'
&& marketplace.status === 'fulfilled';
if (skillsRes.status === 'fulfilled') {
setSkills(skillsRes.value.map((s) => {
const name = s.id || s.name;
const meta = s as SkillPack & { preview?: string; initiator?: 'agent' | 'user' | 'built-in'; source?: string; verified?: boolean; confidence?: number };
return {
name,
preview: meta.preview,
// §D2: the run-and-grade "verified" badge rides the same GET /api/skills row.
verified: meta.verified === true,
confidence: meta.confidence,
// P5/D4: agent provenance is authoritative — an agent-authored skill
// is badged regardless of catalog membership.
initiator: meta.initiator ?? 'user',
source: meta.source,
// Review #5: agent provenance supersedes the name-heuristic 'custom'
// class (D4(iv)) — an agent skill reads as 'installed' + carries the
// 'agent · review' badge, never the user-authored 'custom' label.
status: meta.initiator === 'agent' || !catalogsKnown || catalogNames.has(name)
? 'installed'
: (marketplaceNames.has(name) ? 'marketplace' : 'custom'),
} satisfies Skill;
}));
} else {
setError('Failed to load skills — server may be unreachable');
}
})
.finally(() => setLoading(false));
}, []);
// Defer until the adapter's initial connect attempt has settled (gates on
// `connecting`, not `connected`, so a failed connect still reaches the
// error/Retry UI instead of a permanent skeleton).
useEffect(() => {
if (connecting) return;
load();
}, [load, connecting]);
/**
* §D2 — run the run-and-grade audit for ONE skill (scoped POST = a few LLM
* calls), then reload so a freshly-minted "verified" badge appears. A 403
* (non-PRO tier) throws AdapterHttpError and is routed to the UpgradeModal by
* the fetch chokepoint; any other failure surfaces in the result panel.
*/
const handleVerifySkill = async (skillName: string) => {
setVerifying(skillName);
try {
const { report } = await adapter.auditSkills([skillName]);
const verdict = report.verified.includes(skillName)
? 'verified ✓'
: report.flagged.includes(skillName) ? 'flagged for review (injection)'
: report.inconclusive.includes(skillName) ? 'inconclusive — try again'
: 'did not pass verification';
setTestResult({ name: skillName, preview: `Audit result: ${verdict}` });
load(); // refresh the badge
} catch (err) {
const message = err instanceof Error ? err.message : 'server unreachable';
setTestResult({ name: skillName, preview: `Verification unavailable — ${message}` });
}
finally { setVerifying(null); }
};
// Shared 403→UpgradeModal routing. Other errors fall through to the caller
// so they can show a toast or inline state without duplicating tier logic.
const handleInstallError = (err: unknown, packName: string): boolean => {
const e = err as { status?: number; message?: string; body?: { required?: string; actual?: string } };
if (e.status === 403) {
window.dispatchEvent(new CustomEvent('waggle:tier-insufficient', {
detail: {
required: e.body?.required ?? 'TEAMS',
actual: e.body?.actual ?? 'FREE',
message: `Installing "${packName}" needs the Team plan or an active trial.`,
},
}));
return true;
}
return false;
};
/** Per-source install dispatcher (3A): starter-pack ids install via the
* starter route, capability-pack ids via the pack route. A pack install
* with per-skill failures comes back 422 — surfaced inline, not swallowed. */
const handleInstall = async (pack: CatalogPack) => {
const packId = pack.id || pack.name;
setInstalling(packId);
setInstallError(null);
try {
await adapter.installSkill(packId, pack.installSource ?? 'starter');
setPacks(prev => prev.map(p => (p.id || p.name) === packId ? { ...p, installed: true } : p));
// Reconcile the per-skill list (My Skills/Custom + classification) —
// the optimistic pack flip alone leaves freshly installed skills
// invisible until the window is reopened.
load();
} catch (err) {
if (!handleInstallError(err, packId)) {
setInstallError(err instanceof Error ? err.message : `Failed to install "${packId}"`);
}
} finally { setInstalling(null); }
};
const q = search.toLowerCase();
// Phase 4B (S21 consolidation): the Marketplace tab no longer renders its
// own pack grid — it points at the single Marketplace surface. The
// getMarketplacePacks read above stays load-bearing for skill classification.
const filtered = packs.filter(p =>
(p.name ?? '').toLowerCase().includes(q) ||
(p.description ?? '').toLowerCase().includes(q)
);
const skillRows = (tab === 'custom' ? skills.filter(s => s.status === 'custom') : skills)
.filter(s => !q || s.name.toLowerCase().includes(q) || (s.preview ?? '').toLowerCase().includes(q));
const isSkillTab = tab === 'my-skills' || tab === 'custom' || tab === 'workspace';
const isPackTab = tab === 'starter';
const PackCard = ({ pack, onInstall }: { pack: CatalogPack; onInstall: (pack: CatalogPack) => void }) => {
const trust = trustBadges[pack.trust] || trustBadges.community;
const TrustIcon = trust.icon;
return (
<button
type="button"
onClick={() => setSelectedPack(pack)}
data-testid="skill-pack-card"
className="w-full text-left p-3 rounded-xl bg-secondary/30 border border-border/30 hover:border-primary/40 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-start justify-between mb-1.5">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-honey" />
<span className="text-sm font-display font-medium text-foreground">{pack.name}</span>
</div>
<TrustIcon className={`w-3 h-3 ${trust.color}`} />
</div>
<p className="text-xs text-muted-foreground mb-2">{pack.description}</p>
{pack.skills && pack.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{pack.skills.slice(0, 3).map(s => (
<HintTooltip key={s} content={`Preview skill: ${s} (nothing executes)`}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleTestSkill(s); }}
disabled={testing === s}
className="px-1.5 py-0.5 rounded text-[11px] bg-muted text-muted-foreground hover:bg-primary/20 hover:text-honey transition-colors"
>
{testing === s ? '...' : s}
</button>
</HintTooltip>
))}
{pack.skills.length > 3 && <span className="text-[11px] text-muted-foreground">+{pack.skills.length - 3}</span>}
</div>
)}
<div className="flex items-center justify-between">
<span className={`px-2 py-0.5 rounded text-[11px] font-display capitalize ${categoryColors[pack.category] || 'bg-muted text-muted-foreground'}`}>
{pack.category}
</span>
{pack.installed ? (
<span className="text-[11px] text-emerald-400 flex items-center gap-0.5"><CheckCircle2 className="w-3 h-3" /> Installed</span>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onInstall(pack); }}
disabled={installing === (pack.id || pack.name)}
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-lg bg-primary/20 text-honey hover:bg-primary/30 disabled:opacity-50 transition-colors"
>
{installing === pack.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
Install
</button>
)}
</div>
</button>
);
};
// M-45 / P29 — detail drawer content. Rendered as a fixed overlay so
// it doesn't affect list layout.
const PackDetail = ({ pack }: { pack: CatalogPack }) => {
const trust = describeTrust(pack.trust);
return (
<div
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6"
onClick={() => setSelectedPack(null)}
data-testid="skill-pack-detail-backdrop"
>
<div
className="w-full max-w-lg bg-card border border-border rounded-2xl shadow-xl p-5 space-y-4"
onClick={(e) => e.stopPropagation()}
data-testid="skill-pack-detail"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Package className="w-5 h-5 text-honey shrink-0" />
<h3 className="text-base font-display font-semibold text-foreground truncate">{pack.name}</h3>
</div>
<button
type="button"
onClick={() => setSelectedPack(null)}
className="p-1 rounded hover:bg-muted/50 text-muted-foreground"
aria-label="Close detail"
data-testid="skill-pack-detail-close"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-foreground/90">{pack.description || 'No description provided.'}</p>
<div className="flex flex-wrap gap-2 text-[11px]">
<span className={`px-2 py-0.5 rounded font-display capitalize ${categoryColors[pack.category] || 'bg-muted text-muted-foreground'}`}>
{pack.category || 'uncategorised'}
</span>
<HintTooltip content={trust.explainer}>
<span className="px-2 py-0.5 rounded font-display bg-muted/50 text-muted-foreground" tabIndex={0}>
{trust.label}
</span>
</HintTooltip>
<span className="px-2 py-0.5 rounded font-display bg-muted/50 text-muted-foreground">
{summariseSkills(pack.skills)}
</span>
</div>
<p className="text-[11px] text-muted-foreground">{trust.explainer}</p>
{pack.skills && pack.skills.length > 0 && (
<div>
<p className="text-[11px] font-display uppercase tracking-wide text-muted-foreground mb-1.5">Bundled skills</p>
<div className="flex flex-wrap gap-1">
{pack.skills.map(s => (
<HintTooltip key={s} content={`Preview skill: ${s} (nothing executes)`}>
<button
type="button"
onClick={() => { handleTestSkill(s); }}
disabled={testing === s}
className="px-2 py-0.5 rounded text-[11px] bg-muted text-muted-foreground hover:bg-primary/20 hover:text-honey transition-colors"
>
{testing === s ? '...' : s}
</button>
</HintTooltip>
))}
</div>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
{pack.installed ? (
<span className="text-[11px] text-emerald-400 flex items-center gap-1"><CheckCircle2 className="w-3 h-3" /> Installed</span>
) : (
<button
type="button"
onClick={() => {
setSelectedPack(null);
void handleInstall(pack);
}}
disabled={installing === (pack.id || pack.name)}
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 disabled:opacity-50 transition-colors font-display"
data-testid="skill-pack-detail-install"
>
<Download className="w-3 h-3" /> Install
</button>
)}
</div>
</div>
</div>
);
};
return (
<div className="h-full overflow-auto p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-display font-semibold text-foreground">Skills Hub</h2>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90"
data-testid="skills-hub-create"
>
<Plus className="w-3 h-3" /> Create Skill
</button>
<button
onClick={() => setViewMode('grid')}
aria-label="Grid view"
className={`p-1 rounded transition-colors ${viewMode === 'grid' ? 'text-honey' : 'text-muted-foreground'}`}
>
<Grid3X3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setViewMode('list')}
aria-label="List view"
className={`p-1 rounded transition-colors ${viewMode === 'list' ? 'text-honey' : 'text-muted-foreground'}`}
>
<List className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Tabs — PRD primary vocabulary + secondary panels */}
<div className="flex gap-1 mb-4 p-0.5 rounded-lg bg-muted/50 w-fit flex-wrap" role="tablist" aria-label="Skills Hub sections">
{(Object.keys(TAB_LABELS) as HubTab[]).map(t => (
<HintTooltip key={t} content={TAB_HINTS[t]}>
<button
onClick={() => setTab(t)}
role="tab"
aria-selected={tab === t}
tabIndex={tab === t ? 0 : -1}
className={`px-3 py-1.5 text-xs rounded-md font-display transition-colors ${
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'marketplace' && <Store className="w-3 h-3 inline mr-1" />}
{TAB_LABELS[t]}
</button>
</HintTooltip>
))}
</div>
<div className="flex items-center gap-1.5 bg-muted/50 rounded-lg border border-[var(--line)] px-2 py-1.5 mb-4 transition-colors focus-within:border-[var(--honey-line)] focus-within:shadow-[var(--shadow-honey)]">
<Search className="w-3.5 h-3.5 text-muted-foreground" />
<Input
aria-label="Search skills"
name="skillSearch"
autoComplete="off"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search skills..."
className="flex-1 bg-transparent text-xs border-0 p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
{installError && (
<div role="alert" className="mb-3 flex items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-1.5">
<span className="text-[11px] text-destructive">{installError}</span>
<button onClick={() => setInstallError(null)} aria-label="Dismiss install error" className="shrink-0 text-muted-foreground hover:text-foreground"><X className="w-3 h-3" /></button>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12" role="status" aria-live="polite">
<Loader2 className="w-6 h-6 text-honey animate-spin" />
</div>
)}
{/* Per-skill tabs: My Skills / Custom / Workspace */}
{!loading && isSkillTab && (
tab === 'workspace' ? (
<div className="text-center py-8">
<FileCode2 className="w-8 h-8 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">No workspace-scoped skills to show.</p>
<p className="text-[11px] text-muted-foreground/70 mt-1 max-w-sm mx-auto">
Skills declaring <code className="font-mono">scope: workspace</code> will appear here once the
backend exposes scope metadata.
</p>
</div>
) : error && skillRows.length === 0 ? (
<div role="alert" className="text-center py-8">
<p className="text-xs text-destructive mb-2">{error}</p>
<button onClick={() => load()} className="text-xs text-honey hover:underline">Retry</button>
</div>
) : skillRows.length === 0 ? (
<div className="text-center py-8" role="status">
<FileCode2 className="w-8 h-8 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">
{tab === 'custom'
? (q ? 'No custom skills match your search.' : 'No custom skills yet — Create Skill to author one.')
: (q ? 'No skills match your search.' : 'No skills installed yet — browse Packs or the Marketplace.')}
</p>
</div>
) : (
<ul className="space-y-1">
{skillRows.map(s => (
<SkillRow
key={s.name}
skill={s}
testing={testing === s.name}
verifying={verifying === s.name}
onTest={(sk) => void handleTestSkill(sk.name)}
onEdit={(sk) => setEditingSkill(sk.name)}
onVerify={(sk) => void handleVerifySkill(sk.name)}
/>
))}
</ul>
)
)}
{/* Marketplace tab — Phase 4B (S21): points at the ONE consolidated
Marketplace surface instead of duplicating its grid here. */}
{tab === 'marketplace' && (
<div className="text-center py-10 max-w-sm mx-auto" data-testid="marketplace-pointer">
<Store className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-foreground font-display font-medium mb-1">The Marketplace has moved</p>
<p className="text-[11px] text-muted-foreground mb-3">
Skills, agents, connectors, MCPs, models and templates now live in one consolidated
Marketplace under the dock&rsquo;s Extend zone.
</p>
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent('waggle:open-app', { detail: { appId: 'marketplace' } }))}
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors font-display"
>
Open Marketplace
</button>
</div>
)}
{/* Pack tab: starter + capability packs */}
{isPackTab && (
<>
<div className={viewMode === 'grid' ? 'grid grid-cols-1 sm:grid-cols-2 gap-2' : 'space-y-2'}>
{filtered.map((pack, index) => (
<PackCard
key={`${pack.id || pack.name}-${pack.category || 'uncategorized'}-${index}`}
pack={pack}
onInstall={(p) => void handleInstall(p)}
/>
))}
</div>
{!loading && filtered.length === 0 && (
<div className="text-center py-8">
<Package className="w-8 h-8 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">No catalog packs found</p>
</div>
)}
</>
)}
{/* Tools tab — read-only overview of all agent capabilities */}
{tab === 'tools' && (
<div className="space-y-3">
<p className="text-[11px] text-muted-foreground mb-3">These are the built-in tools the agent can use. They work automatically no setup needed.</p>
{[
{ category: 'File Operations', tools: ['read_file', 'write_file', 'edit_file', 'list_directory', 'find_files', 'delete_path'], desc: 'Read, write, search, and manage files in workspace' },
{ category: 'Code & Shell', tools: ['bash', 'search_content', 'create_directory'], desc: 'Execute commands, search code, manage directories' },
{ category: 'Web & Search', tools: ['web_search', 'web_fetch', 'perplexity_search', 'tavily_search', 'brave_search'], desc: 'Search the web, fetch pages, get real-time information' },
{ category: 'Memory', tools: ['save_memory', 'search_memory', 'get_awareness'], desc: 'Remember facts, search past conversations, track context' },
{ category: 'Documents', tools: ['generate_docx', 'read_docx', 'summarize_document'], desc: 'Create Word docs, read documents, generate reports' },
{ category: 'Git', tools: ['git_status', 'git_commit', 'git_push', 'git_diff', 'git_log'], desc: 'Version control — commit, push, diff, branch management' },
{ category: 'Planning', tools: ['create_plan', 'update_plan', 'execute_plan'], desc: 'Break down tasks, track progress, execute step by step' },
{ category: 'Skills', tools: ['create_skill', 'list_skills', 'suggest_skill'], desc: 'Create, manage, and discover reusable skills' },
{ category: 'Agents', tools: ['spawn_agent'], desc: 'Launch specialist sub-agents for parallel work' },
{ category: 'Scheduling', tools: ['schedule_cron', 'list_crons', 'trigger_cron'], desc: 'Set up recurring tasks and automated runs' },
{ category: 'Browser', tools: ['open_page', 'screenshot', 'click', 'fill'], desc: 'Browse websites, fill forms, take screenshots' },
{ category: 'Connectors', tools: ['29 services'], desc: 'GitHub, Slack, Notion, Jira, and more — connect in the Connectors app' },
].map(group => (
<div key={group.category} className="p-2.5 rounded-lg bg-secondary/30 border border-border/30">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-display font-medium text-foreground">{group.category}</span>
<span className="text-[11px] text-muted-foreground">{group.tools.length} tool{group.tools.length > 1 ? 's' : ''}</span>
</div>
<p className="text-[11px] text-muted-foreground mb-1.5">{group.desc}</p>
<div className="flex flex-wrap gap-1">
{group.tools.map(t => (
<span key={t} className="px-1.5 py-0.5 text-[11px] rounded bg-muted/50 text-muted-foreground font-mono">{t}</span>
))}
</div>
</div>
))}
</div>
)}
{/* Audit tab — the C18 shared install-audit feed (Phase 4B). The feed
spans EVERY capability type, so the copy says so and the type
filter is exposed (claiming "skills and packs" over a mixed feed
would be dishonest). */}
{tab === 'audit' && (
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground">Capability install history skills, packs, MCPs, connectors and marketplace packages. Filter by type.</p>
<InstallAuditPanel showFilter limit={25} />
</div>
)}
{/* M-45 / P29 — pack detail drawer */}
{selectedPack && <PackDetail pack={selectedPack} />}
{/* Skill markdown editor (PATCH /api/skills/:id) */}
<SkillEditorDrawer
skillName={editingSkill}
onOpenChange={(o) => { if (!o) setEditingSkill(null); }}
onSaved={() => load()}
/>
{/* S19 Skill Builder (Phase 3C) — create lands in My Skills and opens
the editor drawer so the new skill is immediately inspectable. */}
{showCreate && (
<SkillBuilder
onCreated={(name) => { setShowCreate(false); load(); setEditingSkill(name); }}
// Refresh on close too: the C14 partial-failure path creates the
// skill on disk even when the user Escapes/Cancels instead of
// pressing Done — the Hub must reflect the file that now exists.
onCancel={() => { setShowCreate(false); load(); }}
onTierError={handleInstallError}
/>
)}
{/* Skill Test Preview — C37: preview only, nothing executes */}
{testResult && (
<div className="border-t border-border/30 p-3 max-h-48 overflow-auto bg-muted/30" data-testid="skill-test-preview">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FlaskConical className="w-3.5 h-3.5 text-honey" />
<span className="text-xs font-display font-medium text-foreground">Test: {testResult.name}</span>
<span className="text-[10px] text-muted-foreground">Preview only nothing was executed</span>
</div>
<button onClick={() => setTestResult(null)} aria-label="Close test preview" className="p-0.5 rounded hover:bg-muted/50">
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
<pre className="text-[11px] font-mono text-muted-foreground whitespace-pre-wrap leading-relaxed">{testResult.preview.slice(0, 500)}{testResult.preview.length > 500 ? '...' : ''}</pre>
</div>
)}
</div>
);
};
export default CapabilitiesApp;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getModels: vi.fn(),
getModel: vi.fn(),
getSettings: vi.fn(),
getTeamMembers: vi.fn(),
setModel: vi.fn(),
patchWorkspace: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks }));
vi.mock('@/hooks/useSessions', () => ({
useSessions: () => ({
sessions: [],
activeSessionId: 'session-1',
setActiveSessionId: vi.fn(),
createSession: vi.fn(),
}),
}));
vi.mock('@/hooks/useChat', () => ({
useChat: () => ({
messages: [],
isLoading: false,
historyLoaded: true,
sendMessage: vi.fn(),
retryLastFailed: vi.fn(),
stopStreaming: vi.fn(),
clearHistory: vi.fn(),
pendingApproval: null,
approveAction: vi.fn(),
}),
}));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock('./ChatApp', () => ({
default: ({ availableModels }: { availableModels: string[] }) => (
<div data-testid="models">{availableModels.join(',')}</div>
),
}));
import ChatWindowInstance from './ChatWindowInstance';
beforeEach(() => {
mocks.getModels
.mockResolvedValueOnce(['openai/existing-model'])
.mockResolvedValueOnce(['openai/model-released-while-open']);
mocks.getModel.mockResolvedValue('openai/existing-model');
mocks.getSettings.mockResolvedValue({});
mocks.getTeamMembers.mockResolvedValue([]);
mocks.setModel.mockResolvedValue(undefined);
mocks.patchWorkspace.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ChatWindowInstance model catalog refresh', () => {
it('reloads the provider-backed model list when Waggle regains focus', async () => {
render(<ChatWindowInstance workspaceId="workspace-1" />);
await waitFor(() => expect(screen.getByTestId('models'))
.toHaveTextContent('openai/existing-model'));
await act(async () => {
window.dispatchEvent(new Event('focus'));
});
await waitFor(() => expect(screen.getByTestId('models'))
.toHaveTextContent('openai/model-released-while-open'));
expect(mocks.getModels).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';
import { useSessions } from '@/hooks/useSessions';
import { useToast } from '@/hooks/use-toast';
import { adapter } from '@/lib/adapter';
import { formatModelLabel } from '@/lib/model-label';
import ChatApp from './ChatApp';
import type { TeamMember } from './ChatApp';
type AutonomyLevel = 'normal' | 'trusted' | 'yolo';
interface ChatWindowInstanceProps {
workspaceId: string;
workspaceName?: string;
initialPersona?: string;
/** QW-1: starter prompt prefilled into the chat input once on first mount. */
initialMessage?: string;
/** F2: auto-send the initialMessage once the chat is ready (wizard "Let's go"). */
autoSendInitial?: boolean;
templateId?: string;
storageType?: 'virtual' | 'local' | 'team';
/**
* Phase A.2: called when the user changes the persona inside this window.
* Should update the window's local persona state via useWindowManager.
* Takes precedence over the legacy workspace-patch path when provided.
*/
onPersonaChange?: (personaId: string) => void;
/** Phase B.5: current autonomy level for this window. */
autonomyLevel?: AutonomyLevel;
/** Phase B.5: expiry of the current elevated autonomy, if any. */
autonomyExpiresAt?: number | null;
/** Phase B.5: change autonomy from inside ChatApp's header. */
onAutonomyChange?: (level: AutonomyLevel, ttlMinutes: number | null) => void;
/** ContextRail: triggered when user double-clicks a message. */
onContextRail?: (target: { type: 'message'; id: string; label: string }) => void;
}
const ChatWindowInstance = ({
workspaceId,
workspaceName,
initialPersona,
initialMessage,
autoSendInitial = false,
templateId,
storageType,
onPersonaChange,
autonomyLevel = 'normal',
autonomyExpiresAt = null,
onAutonomyChange,
onContextRail,
}: ChatWindowInstanceProps) => {
const [currentPersona, setCurrentPersona] = useState(initialPersona || 'general-purpose');
// Sync the local persona state when the parent sends a new initialPersona
// (e.g. when PersonaSwitcher updates the window from outside ChatWindowInstance).
useEffect(() => {
if (initialPersona && initialPersona !== currentPersona) {
setCurrentPersona(initialPersona);
}
}, [initialPersona]);
const { sessions, activeSessionId, setActiveSessionId, createSession } = useSessions(workspaceId);
// chat-session-uuid-title (P2): the server returns a real title derived from the
// first user message, or null for a brand-new untitled session. Render a friendly
// placeholder instead of the raw `session-<uuid>` id — covering both null/empty
// titles and legacy sessions persisted with the id as their title.
const displaySessions = sessions.map(s =>
!s.title
|| /^(?:local-)?session-/.test(s.title)
|| /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.title)
? { ...s, title: 'New session' }
: s,
);
const { messages, isLoading, historyLoaded, sendMessage, retryLastFailed, stopStreaming, clearHistory, pendingApproval, approveAction } = useChat({
workspaceId,
sessionId: activeSessionId,
persona: currentPersona,
autonomy: { level: autonomyLevel, expiresAt: autonomyExpiresAt },
});
const [currentModel, setCurrentModel] = useState<string>('');
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [teamPresence, setTeamPresence] = useState<TeamMember[]>([]);
const { toast } = useToast();
const handlePersonaChange = (personaId: string) => {
setCurrentPersona(personaId);
// Phase A.2: per-window persona. Prefer the window-scoped callback if
// the parent wired it — otherwise fall back to the legacy workspace
// patch so older call sites keep working.
if (onPersonaChange) {
onPersonaChange(personaId);
toast({ title: 'Persona switched', description: `This window now uses ${personaId}` });
return;
}
adapter.patchWorkspace(workspaceId, { persona: personaId })
.then(() => toast({ title: 'Persona updated', description: `Switched to ${personaId}` }))
.catch(() => toast({ title: 'Persona updated locally', description: 'Backend offline — will sync when connected', variant: 'destructive' }));
};
useEffect(() => {
let cancelled = false;
let modelsLanded = false;
let currentLanded = false;
// The sidecar merges LiteLLM, provider API catalogs, and local runtime models.
// Keep an empty list on outage rather than presenting model IDs that may no
// longer exist at the provider.
const fetchModels = async () => {
try {
const models = await adapter.getModels();
if (cancelled) return;
if (models && models.length > 0) {
setAvailableModels(models);
modelsLanded = true;
} else {
setAvailableModels([]);
}
} catch (err) {
console.error('[ChatWindowInstance] fetch models failed:', err);
if (!cancelled) setAvailableModels([]);
}
};
// Try fetching the current active model from the sidecar. Also retries on
// transient failure — the initial render may race the sidecar spawning.
const fetchCurrentModel = async () => {
try {
const model = await adapter.getModel();
if (cancelled) return;
if (typeof model === 'string' && model) {
setCurrentModel(model);
currentLanded = true;
return;
}
const settings = await adapter.getSettings();
if (cancelled) return;
const fromSettings = (settings as { defaultModel?: string; model?: string }).defaultModel
?? (settings as { model?: string }).model;
if (fromSettings) {
setCurrentModel(fromSettings);
currentLanded = true;
}
} catch (err) {
console.error('[ChatWindowInstance] fetch current model failed:', err);
}
};
fetchModels();
fetchCurrentModel();
// Retry loop for the first 20 seconds of a window's life. Stops as soon as
// both the model list and the current model have landed from the server.
let tries = 0;
const retryInterval = setInterval(() => {
tries += 1;
if (cancelled || (modelsLanded && currentLanded) || tries > 10) {
clearInterval(retryInterval);
return;
}
if (!modelsLanded) fetchModels();
if (!currentLanded) fetchCurrentModel();
}, 2000);
// Fetch team members for presence display
const fetchTeam = async () => {
try {
const members = await adapter.getTeamMembers();
if (cancelled) return;
setTeamPresence(members.filter(m => m.status === 'online'));
} catch (err) {
console.error('[ChatWindowInstance] fetch team failed:', err);
if (!cancelled) setTeamPresence([]);
}
};
fetchTeam();
const teamInterval = setInterval(fetchTeam, 10000);
const refreshModelsOnFocus = () => { void fetchModels(); };
window.addEventListener('focus', refreshModelsOnFocus);
return () => {
cancelled = true;
window.removeEventListener('focus', refreshModelsOnFocus);
clearInterval(teamInterval);
clearInterval(retryInterval);
};
}, []);
const handleModelChange = (model: string) => {
setCurrentModel(model);
adapter.setModel(model).catch((err) => console.error('[ChatWindowInstance] set model failed:', err));
adapter.patchWorkspace(workspaceId, { model })
.then(() => toast({ title: 'Model updated', description: `Now using ${formatModelLabel(model)}` }))
.catch(() => toast({ title: 'Model updated locally', description: 'Backend offline — will sync when connected', variant: 'destructive' }));
};
return (
<ChatApp
messages={messages}
isLoading={isLoading}
onSendMessage={sendMessage}
onClearHistory={clearHistory}
pendingApproval={pendingApproval}
onApprove={approveAction}
currentPersona={currentPersona}
onPersonaChange={handlePersonaChange}
currentModel={currentModel}
onModelChange={handleModelChange}
availableModels={availableModels}
teamPresence={teamPresence}
sessions={displaySessions}
activeSessionId={activeSessionId}
onSelectSession={setActiveSessionId}
onNewSession={createSession}
workspaceId={workspaceId}
templateId={templateId}
storageType={storageType}
autonomyLevel={autonomyLevel}
autonomyExpiresAt={autonomyExpiresAt}
onAutonomyChange={onAutonomyChange}
onContextRail={onContextRail}
initialMessage={initialMessage}
autoSendInitial={autoSendInitial}
historyLoaded={historyLoaded}
onRetry={retryLastFailed}
onStopStreaming={stopStreaming}
/>
);
};
export default ChatWindowInstance;

View File

@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
adapter: {
getSystemHealth: vi.fn(),
getAgentCost: vi.fn(),
getConnectors: vi.fn(),
getCronJobs: vi.fn(),
getVault: vi.fn(),
getCapabilitiesStatus: vi.fn(),
getAuditInstalls: vi.fn(),
getCostSummary: vi.fn(),
getWeaverStatus: vi.fn(),
getEventStats: vi.fn(),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/components/os/apps/cockpit/ComplianceDashboard', () => ({
default: () => <div data-testid="compliance-dashboard" />,
}));
import CockpitApp from './CockpitApp';
beforeEach(() => {
mocks.adapter.getSystemHealth.mockResolvedValue({ status: 'ok', uptime: 3600, services: [] });
mocks.adapter.getAgentCost.mockResolvedValue({ totalCost: 0, totalTokens: 0 });
mocks.adapter.getConnectors.mockResolvedValue([]);
mocks.adapter.getCronJobs.mockResolvedValue([]);
mocks.adapter.getVault.mockResolvedValue({ secrets: [] });
mocks.adapter.getCapabilitiesStatus.mockResolvedValue({});
mocks.adapter.getAuditInstalls.mockResolvedValue([]);
mocks.adapter.getCostSummary.mockResolvedValue({ totalTokens: 0, estimatedCost: 0 });
mocks.adapter.getWeaverStatus.mockResolvedValue({
personalMind: { lastConsolidation: null, lastDecay: null, timerActive: false },
workspaces: [],
checkedAt: new Date().toISOString(),
});
mocks.adapter.getEventStats.mockResolvedValue({ byType: {}, total: 0 });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('CockpitApp action names', () => {
it('names the refresh action', async () => {
render(<CockpitApp />);
expect(await screen.findByRole('button', { name: /refresh cockpit/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react';
import { Activity, Server, DollarSign, Clock, Plug, RefreshCw, Timer, Brain, Shield, Network, FileText, ChevronDown, AlertTriangle } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import type { CronJob } from '@/lib/types';
import { describeCronExpr } from '@/lib/cron-presets';
import ComplianceDashboard from './cockpit/ComplianceDashboard';
interface CockpitData {
health?: { status: string; uptime: number; services: { name: string; status: string }[] };
cost?: { totalCost: number; totalTokens: number };
costSummary?: { totalTokens: number; estimatedCost: number; budgetLimit?: number };
connectors?: { id: string; name: string; status: string }[];
crons?: CronJob[];
memoryStats?: { total: number };
vault?: unknown;
capStatus?: unknown;
auditTrail?: unknown[];
weaver?: {
personalMind: { lastConsolidation: string | null; lastDecay: string | null; timerActive: boolean };
workspaces: Array<{ id: string; lastConsolidation: string | null; timerActive: boolean }>;
checkedAt: string;
};
eventStats?: { byType: Record<string, number>; total: number };
}
const CockpitApp = () => {
const [data, setData] = useState<CockpitData>({});
const [loading, setLoading] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
const [offline, setOffline] = useState(false);
const refresh = async () => {
setLoading(true);
try {
const [health, cost, connectors, crons, vault, capStatus, audit, costSum, weaver, evStats] = await Promise.allSettled([
adapter.getSystemHealth(),
adapter.getAgentCost(),
adapter.getConnectors(),
adapter.getCronJobs(),
adapter.getVault(),
adapter.getCapabilitiesStatus(),
adapter.getAuditInstalls(),
adapter.getCostSummary(),
adapter.getWeaverStatus(),
adapter.getEventStats(),
]);
setData({
health: health.status === 'fulfilled' ? health.value : undefined,
cost: cost.status === 'fulfilled' ? cost.value : undefined,
costSummary: costSum.status === 'fulfilled' ? costSum.value : undefined,
connectors: connectors.status === 'fulfilled' ? connectors.value : undefined,
crons: crons.status === 'fulfilled' ? crons.value : undefined,
vault: vault.status === 'fulfilled' ? vault.value : undefined,
capStatus: capStatus.status === 'fulfilled' ? capStatus.value : undefined,
auditTrail: audit.status === 'fulfilled' ? audit.value : undefined,
weaver: weaver.status === 'fulfilled' ? weaver.value : undefined,
eventStats: evStats.status === 'fulfilled' ? evStats.value : undefined,
});
const allFailed = [health, cost, connectors, crons, vault, capStatus, audit].every(r => r.status === 'rejected');
setOffline(allFailed);
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
const interval = setInterval(refresh, 30000);
return () => clearInterval(interval);
}, []);
// Accept common synonyms for healthy/degraded so "ok" isn't rendered as destructive-red.
const HEALTHY = new Set(['healthy', 'ok', 'green', 'up', 'online']);
const DEGRADED = new Set(['degraded', 'warning', 'warn', 'amber', 'yellow']);
const _healthStatus = (data.health?.status ?? '').toLowerCase();
const healthColor = HEALTHY.has(_healthStatus) ? 'text-[var(--healthy)]' : DEGRADED.has(_healthStatus) ? 'text-[var(--attention)]' : 'text-destructive';
return (
<div className="h-full overflow-auto p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-display font-semibold text-foreground">Cockpit</h2>
<button
type="button"
onClick={refresh}
disabled={loading}
aria-label="Refresh Cockpit"
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{offline && (
<div className="mb-4 p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
<div>
<p className="text-xs font-medium text-foreground">Server unreachable</p>
<p className="text-[11px] text-muted-foreground">Could not connect to the backend check Settings</p>
</div>
<button onClick={refresh} className="ml-auto px-2.5 py-1 text-[11px] font-medium rounded-lg bg-muted hover:bg-muted/80 transition-colors flex items-center gap-1">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{/* 1. System Health */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Server className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">System Health</span>
</div>
<div className={`text-lg font-display font-bold capitalize ${healthColor}`}>
{data.health?.status || 'Unknown'}
</div>
{data.health?.uptime && (
<p className="text-[11px] text-muted-foreground mt-1">Uptime: {Math.round(data.health.uptime / 3600)}h</p>
)}
{data.health?.status === 'degraded' && data.health.services && data.health.services.filter(s => s.status !== 'healthy').length > 0 && (
<div className="mt-2 space-y-0.5">
{data.health.services.filter(s => s.status !== 'healthy').map(s => (
<p key={s.name} className="text-[11px] text-[var(--attention)]/80 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--attention)] shrink-0" />
{s.name}: {s.status}
</p>
))}
</div>
)}
{data.health?.status === 'degraded' && (!data.health.services || data.health.services.filter(s => s.status !== 'healthy').length === 0) && (
<p className="text-[11px] text-muted-foreground mt-1">Some services are running at reduced capacity</p>
)}
</div>
{/* 2. Cost Dashboard */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Cost</span>
</div>
<div className="text-lg font-display font-bold text-foreground">
${(data.costSummary?.estimatedCost ?? data.cost?.totalCost ?? 0).toFixed(4)}
</div>
<p className="text-[11px] text-muted-foreground mt-1">
{(data.costSummary?.totalTokens ?? data.cost?.totalTokens ?? 0).toLocaleString()} tokens
{data.costSummary?.budgetLimit != null
? <span className="ml-1">· Budget: ${data.costSummary.budgetLimit}/day</span>
: <span className="ml-1">· Set budget in Settings</span>
}
</p>
</div>
{/* 2b. Memory Weaver */}
{data.weaver && (
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Memory Weaver</span>
</div>
<p className="text-[11px] text-muted-foreground">
Status: <span className={data.weaver.personalMind?.timerActive ? 'text-[var(--healthy)]' : 'text-muted-foreground'}>{data.weaver.personalMind?.timerActive ? 'Active' : 'Idle'}</span>
</p>
{data.weaver.personalMind?.lastConsolidation && (
<p className="text-[11px] text-muted-foreground">
Last consolidation: {new Date(data.weaver.personalMind.lastConsolidation).toLocaleDateString(DATE_LOCALE)}
</p>
)}
</div>
)}
{/* 2c. Event Activity */}
{data.eventStats && data.eventStats.total > 0 && (
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Activity</span>
</div>
<div className="text-lg font-display font-bold text-foreground">
{data.eventStats.total.toLocaleString()}
</div>
<p className="text-[11px] text-muted-foreground mt-1">total events</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{Object.entries(data.eventStats.byType).slice(0, 5).map(([type, count]) => (
<span key={type} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">
{type}: {count}
</span>
))}
</div>
</div>
)}
{/* 3. Cron Schedules */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Timer className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Scheduled Routines</span>
</div>
{data.crons && data.crons.length > 0 ? (
<div className="space-y-1">
{data.crons.slice(0, 3).map(c => (
<div key={c.id} className="flex items-center justify-between text-xs">
<span className="text-foreground truncate">{c.name}</span>
<span className={c.enabled ? 'text-[var(--healthy)]' : 'text-muted-foreground'} title={c.schedule}>{describeCronExpr(c.schedule)}</span>
</div>
))}
{data.crons.length > 3 && (
<button onClick={() => setShowAdvanced(true)} className="text-[11px] text-honey hover:text-honey/80 transition-colors">
+{data.crons.length - 3} more view all
</button>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No routines</p>
)}
</div>
{/* 4. Connectors */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Plug className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Connectors</span>
</div>
{data.connectors && data.connectors.length > 0 ? (
<div className="space-y-1">
{data.connectors.map(c => (
<div key={c.id} className="flex items-center justify-between text-xs">
<span className="text-foreground">{c.name}</span>
<span className={c.status === 'connected' ? 'text-[var(--healthy)]' : 'text-muted-foreground'}>{c.status}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No connectors</p>
)}
</div>
{/* 5. Memory Stats */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30 col-span-2">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Memory</span>
</div>
<p className="text-xs text-muted-foreground">Frame storage active</p>
</div>
</div>
{/* Advanced toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 mt-4 mb-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-0' : '-rotate-90'}`} />
<span className="font-display">Advanced</span>
</button>
{showAdvanced && (
<div className="grid grid-cols-2 gap-3">
{/* 6. Services */}
{data.health?.services && (
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Services</span>
</div>
<div className="space-y-1">
{data.health.services.map(s => (
<div key={s.name} className="flex items-center justify-between text-xs">
<span className="text-foreground">{s.name}</span>
<span className={s.status === 'running' ? 'text-[var(--healthy)]' : 'text-destructive'}>{s.status}</span>
</div>
))}
</div>
</div>
)}
{/* 7. Vault Summary */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Vault</span>
</div>
<p className="text-xs text-muted-foreground">{data.vault ? 'Active' : 'Not configured'}</p>
</div>
{/* 8. Capabilities Overview */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Capabilities</span>
</div>
<p className="text-xs text-muted-foreground">{data.capStatus ? 'Loaded' : 'No data'}</p>
</div>
{/* 9. Agent Topology */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center gap-2 mb-2">
<Network className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Agent Topology</span>
</div>
<p className="text-xs text-muted-foreground">Swarm view</p>
</div>
{/* 10. Audit Trail */}
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30 col-span-2">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-muted-foreground" />
<span className="text-xs font-display font-medium text-foreground">Audit Trail</span>
</div>
{data.auditTrail && Array.isArray(data.auditTrail) && data.auditTrail.length > 0 ? (
<div className="space-y-1">
{data.auditTrail.slice(0, 5).map((item, i) => (
<p key={i} className="text-xs text-muted-foreground truncate">{JSON.stringify(item)}</p>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No audit entries</p>
)}
</div>
</div>
)}
{/* AI Act Compliance */}
<ComplianceDashboard />
</div>
);
};
export default CockpitApp;

View File

@@ -0,0 +1,406 @@
/**
* ConnectorsApp — the Connector Hub (UX-Refactor Phase 4B, S07; PRD §12.7
* "users see exactly what tools are connected and whether data is flowing").
*
* Phase-4B rework:
* - The embedded "MCP Servers" tab departed to the standalone MCP Hub (S08).
* - Rows render via the ConnectorCard DS piece (§14.7 states + lastSyncAt).
* - §8a consumption switch: shared ConnectorDefinition replaces the local
* thin Connector duplicate; categories come from the shared `category`
* field, not a hardcoded id map.
* - New actions: Sync now (C16 health probe + stamp — honest copy, not a data
* re-pull), Revoke (C17 strong path w/ scope-and-consequence confirm incl.
* the shared-Google-token-pair warning) distinct from the lighter
* Disconnect, and a per-connector audit history drawer (C18).
*/
import { useState, useEffect, useCallback } from 'react';
import { AlertTriangle, Loader2, RefreshCw, Zap } from 'lucide-react';
import type { ConnectorDefinition } from '@waggle/shared';
import { recommendConnectors } from '@waggle/shared';
import { adapter } from '@/lib/adapter';
import { useService } from '@/providers/ServiceProvider';
import { useToast } from '@/hooks/use-toast';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
import { actionRisk } from '@/lib/risk-display';
import ConnectorCard, { type ConnectorSetupHint } from './connectors/ConnectorCard';
import InstallAuditPanel from './extend/InstallAuditPanel';
import { formatPersonaName } from '@/lib/persona-display';
import { createSurfaceCache } from '@/lib/surface-cache';
type ConnTab = 'all' | 'connected' | 'available' | 'recommended' | 'activity';
const TAB_LABELS: Record<ConnTab, string> = {
all: 'All',
connected: 'Connected',
available: 'Available',
recommended: 'Recommended',
activity: 'Activity',
};
interface ConnectorsAppProps {
/** Active workspace's persona id — drives the Recommended tab. */
personaId?: string;
}
/** Display labels for the shared ConnectorDefinition.category values. */
const CATEGORY_LABELS: Record<string, string> = {
development: 'Code & DevOps',
communication: 'Communication',
productivity: 'Productivity',
crm: 'CRM & Sales',
storage: 'Cloud Storage',
data: 'Database & Data',
integration: 'Platform',
};
/** Google-family connector ids that share ONE OAuth token pair on the server
* (mirrors the route-side OAUTH_PROVIDER map) — revoking any of them purges
* the pair, so the confirm dialog must surface the blast radius. */
const GOOGLE_FAMILY = new Set(['gcal', 'gdrive', 'gdocs', 'gmail', 'gsheets']);
/** Keep the last connector roster visible while a returning hub revalidates. */
const connectorsRouteCache = createSurfaceCache<ConnectorDefinition[]>();
const CONNECTORS_CACHE_KEY = 'roster';
// eslint-disable-next-line react-refresh/only-export-components -- test-only cache reset
export function resetConnectorsRouteCache(): void {
connectorsRouteCache.resetForTests();
}
const SETUP_HINTS: Record<string, ConnectorSetupHint> = {
github: { url: 'https://github.com/settings/tokens/new', placeholder: 'ghp_...', steps: ['Settings → Developer settings → Personal access tokens', 'Generate with repo, user scopes'] },
slack: { url: 'https://api.slack.com/apps', placeholder: 'xoxb-...', steps: ['Create App → Bot Token Scopes → Install to Workspace'] },
notion: { url: 'https://www.notion.so/my-integrations', placeholder: 'ntn_...', steps: ['Create integration → Copy Internal Integration Token'] },
jira: { url: 'https://id.atlassian.com/manage-profile/security/api-tokens', placeholder: 'ATATT...', steps: ['Account → Security → API Tokens → Create'] },
linear: { url: 'https://linear.app/settings/api', placeholder: 'lin_api_...', steps: ['Settings → API → Create Personal API Key'] },
composio: { url: 'https://app.composio.dev/settings', placeholder: 'cmp_...', steps: ['Settings → API Keys → Copy key (unlocks 250+ services)'] },
discord: { url: 'https://discord.com/developers/applications', placeholder: 'Bot token...', steps: ['Create Application → Bot → Copy Token'] },
};
/**
* The token/email inputs are a single shared state reused across every
* connector row. They must be cleared whenever the expanded connector
* changes (but NOT when re-collapsing the same one) so a credential typed
* for connector A can never be submitted to connector B. Pure so it can be
* regression-tested without rendering React (see phase5b-connectors.test).
*/
export function shouldResetCredentialInputs(prev: string | null, next: string | null): boolean {
return prev !== next;
}
/** Revoke confirm content (C17) — scope-and-consequence, incl. Google pair. */
export function buildRevokeRequest(conn: Pick<ConnectorDefinition, 'id' | 'name'>): ApprovalRequest {
return {
action: `Revoke all access for ${conn.name}? This is the strong path — Disconnect is the lighter option.`,
scope: [
'Deletes every stored credential for this connector',
GOOGLE_FAMILY.has(conn.id)
? 'Purges the SHARED Google OAuth token pair — Gmail, Calendar, Drive, Docs and Sheets will all need to reconnect'
: 'Purges this providers OAuth tokens',
'Writes a revoke entry to the install audit trail',
],
riskLevel: actionRisk('connector-revoke'),
};
}
const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
// Cold-load race guard (the HomeCockpit lesson): wait for the adapter's
// initial connect() to settle before firing authed calls — an unguarded
// mount fetch races the session-token bootstrap and 401s into a
// healthy-looking "0 of 0 connected" hub. (`serviceConnecting` ≠ the local
// `connecting` connect-button busy flag below.)
const { connecting: serviceConnecting } = useService();
const [tab, setTab] = useState<ConnTab>('all');
const cachedConnectors = connectorsRouteCache.read(CONNECTORS_CACHE_KEY);
const [connectors, setConnectors] = useState<ConnectorDefinition[]>(cachedConnectors ?? []);
const [loading, setLoading] = useState(() => !connectorsRouteCache.hasResolved(CONNECTORS_CACHE_KEY));
const [expanded, setExpanded] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState('');
const [emailInput, setEmailInput] = useState('');
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ConnectorDefinition | null>(null);
const [revoking, setRevoking] = useState(false);
const [revokeNotice, setRevokeNotice] = useState<string | null>(null);
const { toast } = useToast();
// Expand a connector (or collapse when re-clicking the open one). Resets the
// token/email inputs whenever the target connector changes (R4-007).
const selectConnector = (id: string | null) => {
setExpanded(prev => {
if (shouldResetCredentialInputs(prev, id)) {
setTokenInput('');
setEmailInput('');
}
return id;
});
};
const loadConnectors = useCallback(async () => {
if (!connectorsRouteCache.hasResolved(CONNECTORS_CACHE_KEY)) setLoading(true);
try {
const data = await adapter.getConnectors();
setConnectors(data);
connectorsRouteCache.write(CONNECTORS_CACHE_KEY, data);
setError(null);
} catch (err) {
console.error('[ConnectorsApp] load failed:', err);
// Preserve a resolved roster during a refresh failure so a returning
// user sees the last known connection state and can retry in place.
if (!connectorsRouteCache.hasResolved(CONNECTORS_CACHE_KEY)) setConnectors([]);
setError(err instanceof Error ? err.message : 'Server unreachable');
} finally { setLoading(false); }
}, []);
useEffect(() => {
if (serviceConnecting) return;
void loadConnectors();
}, [serviceConnecting, loadConnectors]);
const handleConnect = async (id: string) => {
if (!tokenInput.trim()) return;
setConnecting(true);
try {
if (emailInput) {
await adapter.addVaultSecret({ key: `connector:${id}:email`, value: emailInput });
}
await adapter.addVaultSecret({ key: `connector:${id}`, value: tokenInput, type: 'bearer' });
await adapter.connectConnector(id);
setTokenInput('');
setEmailInput('');
setExpanded(null);
await loadConnectors();
} catch (err) {
console.error('[ConnectorsApp] connect failed:', err);
toast({
title: 'Connection failed',
description: err instanceof Error ? err.message : 'Could not connect — check the token and server',
variant: 'destructive',
});
}
finally { setConnecting(false); }
};
const handleDisconnect = async (id: string) => {
try {
await adapter.disconnectConnector(id);
await loadConnectors();
} catch (err) { console.error('[ConnectorsApp] disconnect failed:', err); }
};
/** C17 strong path — runs after the ApprovalModal confirm. */
const handleRevoke = async () => {
if (!revokeTarget) return;
const target = revokeTarget;
setRevoking(true);
try {
const res = await adapter.revokeConnector(target.id);
if (res.ok) {
setRevokeNotice(
`Access revoked for ${target.name}${res.cleanedKeys ?? 0} credential key(s) removed, `
+ `${res.oauthPurged ?? 0} OAuth token(s) purged.`,
);
} else {
// 404 (nothing stored) / 503 (vault down) bodies parse as data —
// never render them as a success claim (error-body-as-data trap).
setRevokeNotice(res.error ?? `Revoke of ${target.name} failed`);
}
await loadConnectors();
} catch (err) {
toast({
title: 'Revoke failed',
description: err instanceof Error ? err.message : 'Server unreachable',
variant: 'destructive',
});
} finally {
setRevoking(false);
setRevokeTarget(null);
}
};
// Group by the shared category field (§8a — no hardcoded id map).
const groupConnectors = (list: ConnectorDefinition[]) => {
const groups = new Map<string, ConnectorDefinition[]>();
for (const c of list) {
const label = (c.category && CATEGORY_LABELS[c.category]) || (c.category ?? 'Other');
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(c);
}
return Array.from(groups, ([category, items]) => ({ category, items }));
};
const connectedCount = connectors.filter(c => c.status === 'connected').length;
const visible = tab === 'connected'
? connectors.filter(c => c.status === 'connected' || c.status === 'expired')
: tab === 'available'
? connectors.filter(c => c.status !== 'connected')
: connectors;
// Recommended tab: persona-aware ids resolved against the live registry.
const recommendedIds = personaId ? recommendConnectors(personaId) : null;
const recommended = recommendedIds
? [...recommendedIds.primary, ...recommendedIds.secondary]
.map(id => connectors.find(c => c.id === id))
.filter((c): c is ConnectorDefinition => c != null)
: [];
// Full-screen loader only on the INITIAL load — background refreshes
// (sync/connect/revoke) keep the rows mounted so card-local state (the C16
// sync notice, lazy health detail) survives instead of being unmounted
// before it ever paints.
if (loading && connectors.length === 0) {
return <div className="flex items-center justify-center h-full"><Loader2 className="w-5 h-5 animate-spin text-honey" /></div>;
}
if (error && connectors.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
<AlertTriangle className="w-8 h-8 text-muted-foreground/30" />
<p className="text-sm font-display font-medium text-foreground">Server unreachable</p>
<p className="text-xs text-muted-foreground max-w-xs">Could not connect to the backend check Settings</p>
<button onClick={loadConnectors} className="mt-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors flex items-center gap-1.5">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
);
}
const renderCard = (conn: ConnectorDefinition, categoryLabel: string) => (
<ConnectorCard
key={conn.id}
conn={conn}
categoryLabel={categoryLabel}
hint={SETUP_HINTS[conn.id]}
expanded={expanded === conn.id}
onToggle={() => selectConnector(expanded === conn.id ? null : conn.id)}
tokenInput={tokenInput}
emailInput={emailInput}
onTokenChange={setTokenInput}
onEmailChange={setEmailInput}
connecting={connecting}
onConnect={() => void handleConnect(conn.id)}
onDisconnect={() => void handleDisconnect(conn.id)}
onRevoke={() => setRevokeTarget(conn)}
onSynced={() => void loadConnectors()}
/>
);
return (
<div className="flex h-full bg-background">
{/* Sidebar tabs. All tabs stay in the Tab order (FilesAppTabs pattern) —
a roving tabIndex without arrow-key handling makes every inactive
tab keyboard-unreachable (WCAG 2.1.1). */}
<div className="w-36 border-r border-border/50 p-2 space-y-0.5 shrink-0" role="tablist" aria-label="Connector Hub sections">
{(Object.keys(TAB_LABELS) as ConnTab[]).map(t => (
<button key={t} onClick={() => setTab(t)}
role="tab"
aria-selected={tab === t}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs transition-colors ${
tab === t ? 'bg-primary/20 text-honey' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}>
{TAB_LABELS[t]}
{t === 'connected' && <span className="ml-auto text-[11px] text-emerald-400">{connectedCount}</span>}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-4 overflow-auto" role="tabpanel">
{/* Revoke result (C17 consequence summary) — hoisted ABOVE the tab
branches so a revoke confirmed from ANY tab (incl. Recommended)
renders its outcome. */}
{revokeNotice && (
<p role="status" data-testid="revoke-notice" className="mb-3 text-[11px] text-foreground bg-muted/40 border border-border/30 rounded-lg px-2.5 py-1.5">
{revokeNotice}
</p>
)}
{tab === 'activity' ? (
<div className="space-y-3">
<div>
<h3 className="text-sm font-display font-semibold text-foreground">Recent Activity</h3>
<p className="text-[11px] text-muted-foreground">Connector installs, syncs and revocations from the shared install-audit trail.</p>
</div>
<InstallAuditPanel type="connector" limit={25} />
</div>
) : tab === 'recommended' ? (
<div className="space-y-3">
<div>
<h3 className="text-sm font-display font-semibold text-foreground">
{personaId ? `Recommended for ${formatPersonaName(personaId)}` : 'Recommended'}
</h3>
<p className="text-[11px] text-muted-foreground">
{personaId
? 'Connectors most relevant to this workspaces persona.'
: 'Open a workspace with a persona to see role-aware recommendations.'}
</p>
</div>
{recommended.length > 0 ? (
<div className="space-y-1.5">
{recommended.map(conn => renderCard(conn, (conn.category && CATEGORY_LABELS[conn.category]) || 'Other'))}
</div>
) : (
<p className="text-xs text-muted-foreground py-6 text-center">No recommendations available.</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-display font-semibold text-foreground">Service Connectors</h3>
<p className="text-[11px] text-muted-foreground">{connectedCount} of {connectors.length} connected the agent can use connected services as tools</p>
</div>
<button onClick={loadConnectors} aria-label="Refresh connectors" className="p-1 rounded hover:bg-muted/50"><RefreshCw className="w-3.5 h-3.5 text-muted-foreground" /></button>
</div>
{/* Composio gateway banner */}
{!connectors.find(c => c.id === 'composio' && c.status === 'connected') && (
<div className="p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-violet-400" />
<p className="text-xs font-display font-semibold text-violet-400">Composio Gateway</p>
</div>
<p className="text-[11px] text-muted-foreground mb-2">
Connect Composio with a single API key to unlock <strong>250+ services</strong> instantly Google Workspace, Slack, Notion, Jira, Salesforce, HubSpot, and more. No individual setup needed.
</p>
<button
onClick={() => selectConnector('composio')}
className="px-2.5 py-1 rounded-lg bg-violet-500/20 text-violet-400 text-[11px] font-display hover:bg-violet-500/30 transition-colors"
>
Set up Composio
</button>
</div>
)}
{groupConnectors(visible).map(group => (
<div key={group.category}>
<p className="text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">{group.category}</p>
<div className="space-y-1.5">
{group.items.map(conn => renderCard(conn, group.category))}
</div>
</div>
))}
{visible.length === 0 && (
<p className="text-xs text-muted-foreground py-6 text-center">
{tab === 'connected' ? 'No connectors connected yet — browse Available to set one up.' : 'No connectors to show.'}
</p>
)}
</div>
)}
</div>
{/* C17 revoke confirm — scope-and-consequence (incl. Google-pair warning) */}
<ApprovalModal
request={revokeTarget ? buildRevokeRequest(revokeTarget) : null}
approveLabel="Revoke access"
busy={revoking}
onApprove={() => void handleRevoke()}
onCancel={() => setRevokeTarget(null)}
/>
</div>
);
};
export default ConnectorsApp;

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { Workspace } from '@/lib/types';
import DashboardApp from './DashboardApp';
const mocks = vi.hoisted(() => ({
adapter: {
getServerUrl: vi.fn(),
getMemoryStats: vi.fn(),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
const workspace: Workspace = {
id: 'workspace-alpha',
name: 'Alpha Workspace',
group: 'Personal',
status: 'active',
persona: 'general-purpose',
updatedAt: '2026-07-09T08:00:00.000Z',
hue: 42,
health: 'healthy',
};
describe('DashboardApp', () => {
beforeEach(() => {
mocks.adapter.getServerUrl.mockReturnValue('http://localhost:17375');
mocks.adapter.getMemoryStats.mockResolvedValue({ total: { frames: 0, entities: 0, relations: 0 } });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ tasks: [] }),
}));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('scopes workspace tile transitions to explicit properties', async () => {
render(
<TooltipProvider>
<DashboardApp
workspaces={[workspace]}
activeWorkspaceId="workspace-alpha"
onSelectWorkspace={vi.fn()}
onCreateWorkspace={vi.fn()}
/>
</TooltipProvider>,
);
const label = await screen.findByText('Alpha Workspace');
const tile = label.closest('button');
expect(tile?.className).not.toContain('transition-all');
expect(tile?.className).toContain('transition-[background-color,border-color,box-shadow]');
});
});

View File

@@ -0,0 +1,308 @@
import { useState, useEffect } from 'react';
import { Plus, Activity, Clock, Brain, ChevronRight, Users, Sparkles, CheckCircle2, Circle } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { getPersonaById } from '@/lib/personas';
import type { Workspace } from '@/lib/types';
import { getAllGroups } from '@/lib/workspace-groups';
import {
computeBrainHealth,
brainHealthTier,
brainHealthBreakdown,
TIER_LABELS,
type BrainHealthCounts,
} from '@/lib/brain-health';
const TEMPLATE_LABELS: Record<string, string> = {
'sales-pipeline': 'Sales',
'research-project': 'Research',
'code-review': 'Code Review',
'marketing-campaign': 'Marketing',
'product-launch': 'Product Launch',
'legal-review': 'Legal',
'agency-consulting': 'Consulting',
'blank': 'Custom',
};
const PERSONA_LABELS: Record<string, string> = {
// Universal modes
'general-purpose': 'General',
'planner': 'Planner',
'verifier': 'Verifier',
'coordinator': 'Coordinator',
// Knowledge workers
'researcher': 'Researcher',
'writer': 'Writer',
'analyst': 'Analyst',
'coder': 'Coder',
// Domain specialists
'project-manager': 'PM',
'executive-assistant': 'EA',
'sales-rep': 'Sales Rep',
'marketer': 'Marketer',
'product-manager-senior': 'Senior PM',
'hr-manager': 'HR',
'legal-professional': 'Legal',
'finance-owner': 'Finance',
'consultant': 'Consultant',
'support-agent': 'Support',
'ops-manager': 'Ops',
'data-engineer': 'Data Eng',
'recruiter': 'Recruiter',
'creative-director': 'Creative',
};
interface DashboardAppProps {
workspaces: Workspace[];
activeWorkspaceId: string | null;
onSelectWorkspace: (id: string) => void;
onCreateWorkspace: () => void;
}
const healthColors: Record<string, string> = {
healthy: 'bg-emerald-400',
degraded: 'bg-amber-400',
error: 'bg-destructive',
};
// L-11 / A11Y-6: shape + label per status so colorblind users can
// distinguish without relying on colour alone. The unicode glyph
// doubles as a visual signal independent of colour channels.
const healthShape: Record<string, { glyph: string; label: string }> = {
healthy: { glyph: '●', label: 'Healthy' },
degraded: { glyph: '◐', label: 'Degraded' },
error: { glyph: '▲', label: 'Error' },
};
const groupColors: Record<string, string> = {
Personal: 'bg-primary/20 text-honey',
Work: 'bg-sky-500/20 text-sky-400',
Research: 'bg-violet-500/20 text-violet-400',
};
const DashboardApp = ({ workspaces, activeWorkspaceId, onSelectWorkspace, onCreateWorkspace }: DashboardAppProps) => {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<string>('All');
const [tasks, setTasks] = useState<Array<{ id: string; title: string; status: string; workspaceName: string; updatedAt: string }>>([]);
const [brainCounts, setBrainCounts] = useState<BrainHealthCounts>({ frames: 0, entities: 0, relations: 0 });
useEffect(() => {
fetch(`${adapter.getServerUrl()}/api/tasks?status=open`)
.then(r => r.json())
.then(data => setTasks((data.tasks ?? []).slice(0, 8)))
.catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
adapter.getMemoryStats().then(stats => {
if (!cancelled) setBrainCounts(stats.total);
});
return () => { cancelled = true; };
}, []);
// Get group names: standard groups first, then any custom, with "All" at front
const existingGroups = workspaces.map(ws => ws.group || 'Personal');
const groupNames = ['All', ...getAllGroups(existingGroups).filter(g => existingGroups.includes(g))];
// Filter workspaces
const filtered = activeFilter === 'All'
? workspaces
: workspaces.filter(ws => (ws.group || 'Personal') === activeFilter);
// Group filtered workspaces
const groups = filtered.reduce<Record<string, Workspace[]>>((acc, ws) => {
const group = ws.group || 'Personal';
if (!acc[group]) acc[group] = [];
acc[group].push(ws);
return acc;
}, {});
const brainScore = computeBrainHealth(brainCounts);
const brainTier = brainHealthTier(brainScore);
const brainParts = brainHealthBreakdown(brainCounts);
return (
<div className="h-full overflow-auto p-4">
{/* M-27 / ENG-6 · Brain Health — weighted saturation across
frames, entities, relations. Per-dimension contribution in
the title tooltip so the user can see WHICH dimension is
lagging when the score is low. */}
<HintTooltip content={`Frames: ${brainCounts.frames.toLocaleString()} (+${brainParts.frames} pts) · Entities: ${brainCounts.entities.toLocaleString()} (+${brainParts.entities}) · Relations: ${brainCounts.relations.toLocaleString()} (+${brainParts.relations})`}>
<div
className="mb-4 rounded-xl border border-border/30 bg-secondary/20 px-3 py-2.5"
data-testid="brain-health-card"
tabIndex={0}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<Brain className="w-3.5 h-3.5 text-honey" />
<span className="text-[11px] font-display font-semibold text-foreground">Brain Health</span>
<span className="text-[10px] text-muted-foreground" data-testid="brain-health-tier">
{TIER_LABELS[brainTier]}
</span>
</div>
<span
className="text-xs font-display font-semibold text-honey tabular-nums"
data-testid="brain-health-score"
>
{brainScore}%
</span>
</div>
<div className="h-1 rounded-full bg-muted overflow-hidden">
{/* duration-500 is deliberate: a data-driven progress fill reads as a
slow reveal, one tier beyond the --mo-settle (400ms) interaction band. */}
<div
className="h-full bg-primary transition-[width] duration-500"
style={{ width: `${brainScore}%` }}
/>
</div>
</div>
</HintTooltip>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-display font-semibold text-foreground">Workspaces</h2>
<button
onClick={onCreateWorkspace}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-display rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> New
</button>
</div>
{/* Group filter tabs */}
{groupNames.length > 2 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{groupNames.map(g => (
<button key={g} onClick={() => setActiveFilter(g)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-display transition-colors ${
activeFilter === g ? 'bg-primary text-primary-foreground' : 'bg-secondary/50 text-muted-foreground hover:text-foreground'
}`}>
{g}
{g !== 'All' && <span className="ml-1 text-[11px] opacity-70">({workspaces.filter(ws => (ws.group || 'Personal') === g).length})</span>}
</button>
))}
</div>
)}
{Object.entries(groups).map(([group, wsList]) => (
<div key={group} className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 rounded text-[11px] font-display font-medium ${groupColors[group] || 'bg-muted text-muted-foreground'}`}>
{group}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{wsList.map(ws => {
const persona = ws.persona ? getPersonaById(ws.persona) : null;
const isActive = ws.id === activeWorkspaceId;
return (
<button
key={ws.id}
onClick={() => onSelectWorkspace(ws.id)}
onMouseEnter={() => setHoveredId(ws.id)}
onMouseLeave={() => setHoveredId(null)}
className={`relative text-left p-3 rounded-xl border transition-[background-color,border-color,box-shadow] duration-mo-base ${
isActive
? 'border-primary/50 bg-primary/10 shadow-lg shadow-primary/10'
: 'border-border/50 bg-secondary/30 hover:bg-secondary/50 hover:border-border'
}`}
style={ws.hue ? { borderLeftColor: `hsl(${ws.hue}, 70%, 50%)`, borderLeftWidth: '3px' } : undefined}
>
<div className="flex items-start gap-2.5">
{persona ? (
<Avatar className="w-8 h-8 shrink-0">
<AvatarImage src={persona.avatar} />
<AvatarFallback className="text-[11px] bg-primary/20">{persona.name[0]}</AvatarFallback>
</Avatar>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Brain className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-display font-medium text-foreground truncate">{ws.name}</span>
<HintTooltip content={healthShape[ws.health || 'healthy'].label}>
<span
className={`text-[11px] leading-none shrink-0 ${healthColors[ws.health || 'healthy'].replace('bg-', 'text-')}`}
role="img"
aria-label={`${healthShape[ws.health || 'healthy'].label} workspace`}
tabIndex={0}
>
{healthShape[ws.health || 'healthy'].glyph}
</span>
</HintTooltip>
{ws.shared && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-sky-500/15 text-sky-400 text-[11px] font-display shrink-0">
<Users className="w-2.5 h-2.5" /> Team
</span>
)}
</div>
{/* Agent identity: template + persona */}
{(ws.templateId || ws.persona) && (
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
{ws.templateId && ws.templateId !== 'blank' && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-primary/10 text-honey text-[11px] font-display">
<Sparkles className="w-2.5 h-2.5" />
{TEMPLATE_LABELS[ws.templateId] || ws.templateId}
</span>
)}
{ws.persona && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-accent/50 text-accent-foreground text-[11px] font-display">
{PERSONA_LABELS[ws.persona] || persona?.name || ws.persona}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground">
{ws.memoryCount !== undefined && (
<span className="flex items-center gap-0.5"><Brain className="w-2.5 h-2.5" />{ws.memoryCount}</span>
)}
{ws.lastActive && (
<span className="flex items-center gap-0.5"><Clock className="w-2.5 h-2.5" />{new Date(ws.lastActive).toLocaleDateString(DATE_LOCALE)}</span>
)}
</div>
</div>
<ChevronRight className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${hoveredId === ws.id ? 'translate-x-0.5' : ''}`} />
</div>
</button>
);
})}
</div>
</div>
))}
{/* Cross-workspace tasks */}
{tasks.length > 0 && (
<div className="mt-4 p-3 rounded-xl bg-secondary/20 border border-border/30">
<h3 className="text-xs font-display font-semibold text-foreground mb-2 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--honey-500)' }} />
Open Tasks
</h3>
<div className="space-y-1.5">
{tasks.map(t => (
<div key={t.id} className="flex items-center gap-2 text-xs">
<Circle className="w-3 h-3 text-muted-foreground/40 shrink-0" />
<span className="text-foreground flex-1 truncate">{t.title}</span>
<span className="text-[11px] text-muted-foreground shrink-0">{t.workspaceName}</span>
</div>
))}
</div>
</div>
)}
{workspaces.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Activity className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground mb-2">No workspaces yet</p>
<button onClick={onCreateWorkspace} className="text-xs text-honey hover:text-honey/80">Create your first workspace</button>
</div>
)}
</div>
);
};
export default DashboardApp;

View File

@@ -0,0 +1,457 @@
import { useState, useMemo } from 'react';
import { Activity, Loader2, CheckCircle2, XCircle, Zap, MessageSquare, Clock, ChevronRight, StopCircle, GitBranch, Circle } from 'lucide-react';
import type { AgentStep } from '@/lib/types';
import { decodeHtmlEntities } from '@/lib/decode-entities';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { DATE_LOCALE } from '@/lib/date-locale';
const stepIcons: Record<string, React.ElementType> = {
think: Activity,
tool_call: Zap,
tool_result: CheckCircle2,
response: MessageSquare,
error: XCircle,
spawn: GitBranch,
};
// FR #19: defensive formatters for partially-populated event payloads.
// Some emitters drop type/description/timestamp on the floor; without
// these guards the UI renders the literal string "undefined" and
// "Invalid Date".
function formatType(type: string | null | undefined): string {
if (!type || typeof type !== 'string') return 'unknown';
return type.replace(/_/g, ' ');
}
function formatTimestamp(ts: string | number | null | undefined): string {
if (ts === null || ts === undefined || ts === '') return 'just now';
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? 'just now' : d.toLocaleTimeString(DATE_LOCALE);
}
function formatDescription(
description: string | null | undefined,
type: string | null | undefined,
): string {
if (description && typeof description === 'string' && description !== 'undefined') {
return decodeHtmlEntities(description);
}
return type ? `${formatType(type)} event` : 'Unknown event';
}
const stepColors: Record<string, string> = {
running: 'text-honey border-primary/30',
complete: 'text-emerald-400 border-emerald-400/30',
error: 'text-destructive border-destructive/30',
};
interface EventsAppProps {
steps: AgentStep[];
autoScroll: boolean;
onToggleAutoScroll: () => void;
filter: string | null;
onFilterChange: (f: string | null) => void;
onAbort?: () => void;
/** P7/D15 B5: a load failure must not read as "No events yet". */
error?: string | null;
}
const StepCard = ({ step, onAbort }: { step: AgentStep; onAbort?: () => void }) => {
const [expanded, setExpanded] = useState(false);
const Icon = stepIcons[step.type] || Activity;
const color = stepColors[step.status] || 'text-muted-foreground border-border/30';
return (
<div className={`rounded-lg border ${color} bg-secondary/20 overflow-hidden`}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-start gap-2 p-2 text-left"
>
<div className="mt-0.5">
{step.status === 'running' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground">{formatDescription(step.description, step.type)}</p>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<span className="capitalize">{formatType(step.type)}</span>
{step.duration && <span>{step.duration}ms</span>}
<span>{formatTimestamp(step.timestamp)}</span>
</div>
</div>
{step.status === 'running' && onAbort && (
<HintTooltip content="Cancel execution">
<button
onClick={(e) => { e.stopPropagation(); onAbort(); }}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-destructive/20 text-destructive text-[11px] font-display hover:bg-destructive/30 transition-colors shrink-0"
>
<StopCircle className="w-3 h-3" /> Cancel
</button>
</HintTooltip>
)}
{step.details && (
<ChevronRight className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-90' : ''}`} />
)}
</button>
{expanded && step.details && (
<div className="px-3 pb-2 pt-0">
<pre className="text-[11px] text-muted-foreground bg-background/50 rounded p-2 overflow-x-auto max-h-32">
{JSON.stringify(step.details, null, 2)}
</pre>
</div>
)}
</div>
);
};
/* ── Agent Tree types & helpers ── */
interface AgentNode {
id: string;
name: string;
status: 'running' | 'complete' | 'error';
persona?: string;
model?: string;
task?: string;
timestamp: string;
children: AgentNode[];
stepCount: number;
}
function buildAgentTree(steps: AgentStep[]): AgentNode[] {
const nodeMap = new Map<string, AgentNode>();
const childToParent = new Map<string, string>();
// Root agent always exists if there are steps
const rootId = 'root-agent';
const rootNode: AgentNode = {
id: rootId,
name: 'Primary Agent',
status: 'running',
timestamp: steps[0]?.timestamp || new Date().toISOString(),
children: [],
stepCount: 0,
};
nodeMap.set(rootId, rootNode);
for (const step of steps) {
// Count steps for root unless assigned to a child
const agentId = (step.details?.agentId as string) || rootId;
if (step.type === 'spawn') {
const childId = (step.details?.childAgentId as string) || (step.details?.workspaceId as string) || `spawn-${step.id}`;
const parentId = (step.details?.parentAgentId as string) || agentId;
const childNode: AgentNode = {
id: childId,
name: (step.details?.workspaceName as string) || (step.details?.persona as string) || step.description || `Sub-agent`,
status: step.status,
persona: step.details?.persona as string,
model: step.details?.model as string,
task: step.details?.task as string || step.description,
timestamp: step.timestamp,
children: [],
stepCount: 0,
};
nodeMap.set(childId, childNode);
childToParent.set(childId, parentId);
}
// Increment step count for the relevant agent
const ownerNode = nodeMap.get(agentId);
if (ownerNode) {
ownerNode.stepCount++;
// Update status to the latest
if (step.status === 'error') ownerNode.status = 'error';
else if (step.status === 'complete' && ownerNode.status !== 'error') ownerNode.status = 'complete';
} else {
rootNode.stepCount++;
}
}
// If no spawns, mark root based on steps
const hasRunning = steps.some(s => s.status === 'running');
const hasError = steps.some(s => s.status === 'error');
if (hasError) rootNode.status = 'error';
else if (!hasRunning && steps.length > 0) rootNode.status = 'complete';
// Wire children to parents
for (const [childId, parentId] of childToParent) {
const parent = nodeMap.get(parentId) || rootNode;
const child = nodeMap.get(childId);
if (child) parent.children.push(child);
}
return [rootNode];
}
const statusDotColor: Record<string, string> = {
running: 'bg-primary animate-pulse',
complete: 'bg-emerald-400',
error: 'bg-destructive',
};
const TreeNode = ({ node, depth = 0 }: { node: AgentNode; depth?: number }) => {
const [expanded, setExpanded] = useState(true);
const hasChildren = node.children.length > 0;
return (
<div>
<button
onClick={() => hasChildren && setExpanded(!expanded)}
className={`w-full flex items-center gap-2 p-2 rounded-lg hover:bg-secondary/30 transition-colors text-left group ${
depth === 0 ? '' : ''
}`}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
>
{/* Tree connector line */}
{depth > 0 && (
<div className="flex items-center gap-0.5 shrink-0">
<div className="w-3 h-px bg-border/50" />
</div>
)}
{/* Expand/collapse */}
{hasChildren ? (
<ChevronRight className={`w-3 h-3 text-muted-foreground transition-transform shrink-0 ${expanded ? 'rotate-90' : ''}`} />
) : (
<Circle className="w-2 h-2 text-muted-foreground/30 shrink-0" />
)}
{/* Status dot */}
<div className={`w-2 h-2 rounded-full shrink-0 ${statusDotColor[node.status] || 'bg-muted-foreground'}`} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-display font-medium text-foreground truncate">{node.name}</span>
{node.persona && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-primary/10 text-honey shrink-0">{node.persona}</span>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground mt-0.5">
{node.model && <span>{node.model}</span>}
<span>{node.stepCount} steps</span>
<span>{formatTimestamp(node.timestamp)}</span>
</div>
{node.task && depth > 0 && (
<p className="text-[11px] text-muted-foreground/70 mt-0.5 truncate">{node.task}</p>
)}
</div>
{/* Status badge */}
<span className={`text-[11px] capitalize shrink-0 ${
node.status === 'running' ? 'text-honey' : node.status === 'error' ? 'text-destructive' : 'text-emerald-400'
}`}>
{node.status}
</span>
</button>
{/* Children */}
{expanded && hasChildren && (
<div className="relative">
{/* Vertical connector line */}
<div
className="absolute top-0 bottom-0 w-px bg-border/30"
style={{ left: `${depth * 20 + 22}px` }}
/>
{node.children.map(child => (
<TreeNode key={child.id} node={child} depth={depth + 1} />
))}
</div>
)}
</div>
);
};
const AgentTreeView = ({ steps }: { steps: AgentStep[] }) => {
const tree = useMemo(() => buildAgentTree(steps), [steps]);
const spawnCount = steps.filter(s => s.type === 'spawn').length;
if (steps.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center">
<GitBranch className="w-10 h-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">No agent activity</p>
<p className="text-xs text-muted-foreground/60">Agent tree will appear when agents are running</p>
</div>
);
}
return (
<div className="space-y-1">
<div className="flex items-center gap-2 mb-3 px-2">
<GitBranch className="w-3.5 h-3.5 text-honey" />
<span className="text-[11px] font-display text-muted-foreground uppercase">
Agent Hierarchy
</span>
<span className="text-[11px] text-muted-foreground">
· {spawnCount} spawn{spawnCount !== 1 ? 's' : ''}
</span>
</div>
{tree.map(node => (
<TreeNode key={node.id} node={node} />
))}
</div>
);
};
/* ── Main Events App ── */
const EventsApp = ({ steps, autoScroll, onToggleAutoScroll, filter, onFilterChange, onAbort, error }: EventsAppProps) => {
const [tab, setTab] = useState<'live' | 'tree' | 'replay'>('live');
const types = ['think', 'tool_call', 'tool_result', 'response', 'error', 'spawn'];
const filteredSteps = filter ? steps.filter(s => s.type === filter) : steps;
// Group steps by session/time for replay
const stepsByTime = steps.reduce<Record<string, AgentStep[]>>((acc, step) => {
// FR #19: events without a parseable timestamp would otherwise group
// under the literal "Invalid Date" key. Bucket them under "Earlier"
// so the day-grouped replay panel still reads cleanly.
const d = step.timestamp ? new Date(step.timestamp) : null;
const timeKey = d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(DATE_LOCALE) : 'Earlier';
if (!acc[timeKey]) acc[timeKey] = [];
acc[timeKey].push(step);
return acc;
}, {});
return (
<div className="flex h-full">
{/* Filters */}
<div className="w-36 border-r border-border/50 p-2 space-y-1 shrink-0">
{/* Tabs */}
<div className="flex gap-0.5 mb-3 p-0.5 rounded-lg bg-muted/50">
{(['live', 'tree', 'replay'] as const).map(t => (
<HintTooltip
key={t}
content={
t === 'live' ? 'Stream of every think/tool call/response as the agent runs right now' :
t === 'tree' ? 'Hierarchical view showing how sub-agents spawned from each turn' :
'Group past runs by day — inspect or re-open a prior session\'s full trace'
}
>
<button
onClick={() => setTab(t)}
className={`flex-1 text-[11px] py-1 rounded font-display transition-colors capitalize ${
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
}`}
>
{t}
</button>
</HintTooltip>
))}
</div>
{tab !== 'tree' && (
<>
<p className="text-[11px] font-display text-muted-foreground uppercase mb-2">Filter by type</p>
<button
onClick={() => onFilterChange(null)}
className={`w-full text-left text-xs px-2 py-1.5 rounded-lg transition-colors ${
!filter ? 'bg-primary/20 text-honey' : 'text-muted-foreground hover:text-foreground'
}`}
>All events</button>
{types.map(t => (
<button
key={t}
onClick={() => onFilterChange(t)}
className={`w-full text-left text-xs px-2 py-1.5 rounded-lg transition-colors capitalize ${
filter === t ? 'bg-primary/20 text-honey' : 'text-muted-foreground hover:text-foreground'
}`}
>{t.replace('_', ' ')}</button>
))}
<div className="pt-2 mt-2 border-t border-border/30">
<button
onClick={onToggleAutoScroll}
className={`w-full text-xs px-2 py-1.5 rounded-lg transition-colors ${
autoScroll ? 'bg-primary/20 text-honey' : 'text-muted-foreground'
}`}
>Auto-scroll {autoScroll ? 'ON' : 'OFF'}</button>
</div>
</>
)}
{tab === 'tree' && (
<div className="space-y-2">
<p className="text-[11px] font-display text-muted-foreground uppercase">Legend</p>
<div className="space-y-1.5">
{[
{ color: 'bg-primary', label: 'Running' },
{ color: 'bg-emerald-400', label: 'Complete' },
{ color: 'bg-destructive', label: 'Error' },
].map(l => (
<div key={l.label} className="flex items-center gap-2 text-[11px] text-muted-foreground">
<div className={`w-2 h-2 rounded-full ${l.color}`} />
{l.label}
</div>
))}
</div>
</div>
)}
{onAbort && steps.some(s => s.status === 'running') && (
<div className="pt-2 mt-2 border-t border-border/30">
<button
onClick={onAbort}
className="w-full flex items-center justify-center gap-1.5 text-xs px-2 py-2 rounded-lg bg-destructive/20 text-destructive hover:bg-destructive/30 transition-colors font-display"
>
<StopCircle className="w-3.5 h-3.5" /> Abort Agent
</button>
</div>
)}
<div className="pt-2 mt-2 border-t border-border/30">
<p className="text-[11px] text-muted-foreground">{steps.length} events</p>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-auto p-3 space-y-1.5">
{tab === 'tree' ? (
<AgentTreeView steps={steps} />
) : tab === 'live' ? (
<>
{/* P7/D15 B5: useEvents already returns `error` — surface it instead
of the "No events yet" empty when the load failed. */}
{error && filteredSteps.length === 0 && (
<div role="alert" className="flex flex-col items-center justify-center h-full text-center">
<XCircle className="w-10 h-10 text-destructive/50 mb-3" />
<p className="text-sm text-foreground">Couldn't load events</p>
<p className="text-xs text-muted-foreground/60 max-w-xs">The activity feed is unreachable this is a load error, not an empty timeline.</p>
</div>
)}
{!error && filteredSteps.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Activity className="w-10 h-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">No events yet</p>
<p className="text-xs text-muted-foreground/60">Events will appear here as the agent executes</p>
</div>
)}
{filteredSteps.map(step => <StepCard key={step.id} step={step} onAbort={onAbort} />)}
</>
) : (
<>
{Object.keys(stepsByTime).length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Activity className="w-10 h-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">No events yet</p>
</div>
)}
{Object.entries(stepsByTime).map(([date, dateSteps]) => (
<div key={date}>
<div className="flex items-center gap-2 mb-2 mt-3 first:mt-0">
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-[11px] font-display text-muted-foreground">{date}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
{dateSteps.map(step => <StepCard key={step.id} step={step} />)}
</div>
))}
</>
)}
</div>
</div>
);
};
export default EventsApp;

View File

@@ -0,0 +1,124 @@
/** Cross-workspace Files rail behavior: file copies are real, folders are explicit. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@/components/ui/tooltip';
const mocks = vi.hoisted(() => ({
adapter: {
listFiles: vi.fn(),
copyFileBetweenWorkspaces: vi.fn(),
},
toast: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock('@/lib/app-deeplink', () => ({ consumeDeepLink: () => null }));
import FilesApp, { resetFilesRouteCache } from './FilesApp';
const workspaces = [
{ id: 'source', name: 'Source workspace', group: 'Test' },
{ id: 'target', name: 'Target workspace', group: 'Test' },
];
const files = [
{ name: 'brief.md', path: '/brief.md', type: 'file' as const, size: 12 },
{ name: 'research', path: '/research', type: 'directory' as const },
];
function renderFiles() {
return render(
<TooltipProvider>
<FilesApp
workspaceId="source"
workspaceName="Source workspace"
workspaces={workspaces}
onSelectWorkspace={vi.fn()}
/>
</TooltipProvider>,
);
}
function dragDataTransfer() {
return {
effectAllowed: 'none',
setData: vi.fn(),
};
}
beforeEach(() => {
resetFilesRouteCache();
mocks.adapter.listFiles.mockResolvedValue(files);
mocks.adapter.copyFileBetweenWorkspaces.mockResolvedValue({
name: 'brief.md', path: '/brief.md', type: 'file', size: 12,
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('FilesApp cross-workspace copy', () => {
it('does not show the previous workspace while the selected workspace loads', async () => {
let resolveTarget: ((value: typeof files) => void) | undefined;
mocks.adapter.listFiles.mockImplementation((workspaceId: string) => {
if (workspaceId === 'source') return Promise.resolve(files);
return new Promise(resolve => { resolveTarget = resolve; });
});
const view = renderFiles();
expect(await screen.findByText('brief.md')).toBeInTheDocument();
const targetFiles = [{ name: 'target.md', path: '/target.md', type: 'file' as const, size: 18 }];
view.rerender(
<TooltipProvider>
<FilesApp
workspaceId="target"
workspaceName="Target workspace"
workspaces={workspaces}
onSelectWorkspace={vi.fn()}
/>
</TooltipProvider>,
);
await waitFor(() => expect(screen.queryByText('brief.md')).not.toBeInTheDocument());
expect(screen.getByTestId('files-loading')).toBeInTheDocument();
resolveTarget?.(targetFiles);
expect(await screen.findByText('target.md')).toBeInTheDocument();
});
it('copies a dragged file to the target workspace root and confirms success', async () => {
renderFiles();
const row = await screen.findByText('brief.md');
const target = screen.getByRole('button', { name: 'Target workspace' });
fireEvent.dragStart(row.closest('tr')!, { dataTransfer: dragDataTransfer() });
fireEvent.drop(target, { dataTransfer: dragDataTransfer() });
await waitFor(() => expect(mocks.adapter.copyFileBetweenWorkspaces).toHaveBeenCalledWith(
'source', 'target', '/brief.md', '/brief.md',
));
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({
title: '1 file copied',
}));
});
it('explains that folders must be moved within a workspace', async () => {
renderFiles();
const row = (await screen.findAllByText('research'))
.map(element => element.closest('tr'))
.find(candidate => candidate?.draggable);
expect(row).toBeTruthy();
const target = screen.getByRole('button', { name: 'Target workspace' });
fireEvent.dragStart(row!, { dataTransfer: dragDataTransfer() });
fireEvent.drop(target, { dataTransfer: dragDataTransfer() });
await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({
title: 'Select files to copy',
variant: 'destructive',
})));
expect(mocks.adapter.copyFileBetweenWorkspaces).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,894 @@
/**
* FilesApp — Layout shell for the file manager.
* Sub-components: FileTree, FilePreview, FileActions, FileUploadZone.
*/
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Folder, Upload, Download,
Trash2, Copy, Scissors, ClipboardPaste, Edit, FolderPlus, X as XIcon,
RefreshCw, CheckSquare, XSquare, FolderInput,
Info, Shield, MapPin, Clock, Hash, Lock, Unlock, FileText, HardDrive,
Loader2, AlertTriangle,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { FileEntry, StorageType, Workspace } from '@/lib/types';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import { getFileIcon, formatSize, STORAGE_LABELS, normalizeWorkspacePath } from './files/file-utils';
import { consumeDeepLink } from '@/lib/app-deeplink';
import { useToast } from '@/hooks/use-toast';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { createSurfaceCache, surfaceCacheKey } from '@/lib/surface-cache';
import FileTree from './files/FileTree';
import FilePreview from './files/FilePreview';
import FileActions from './files/FileActions';
import FileUploadZone from './files/FileUploadZone';
import WorkspaceRail from './files/WorkspaceRail';
/* ── Inline version history panel ── */
const VersionHistory = ({ workspaceId, fileName }: { workspaceId: string; fileName: string }) => {
const [versions, setVersions] = useState<{ version: number; createdAt: string; sizeBytes: number }[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
adapter.getDocumentVersions(workspaceId, fileName).then(v => { setVersions(v); setLoaded(true); }).catch(() => setLoaded(true));
}, [workspaceId, fileName]);
if (!loaded || versions.length === 0) return null;
return (
<>
<div className="h-px bg-border/20" />
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Version History</h4>
<div className="space-y-1.5">
{versions.map(v => (
<div key={v.version} className="flex items-center justify-between text-xs">
<span className="text-foreground">v{v.version}</span>
<span className="text-muted-foreground">{formatSize(v.sizeBytes)}</span>
<span className="text-muted-foreground/60 text-[11px]">{new Date(v.createdAt).toLocaleDateString(DATE_LOCALE)}</span>
</div>
))}
</div>
</div>
</>
);
};
/* ── Helpers ── */
const isPreviewable = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp',
'md', 'txt', 'log', 'csv', 'json', 'yaml', 'toml', 'xml',
'js', 'ts', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h',
'html', 'css', 'sh', 'bash', 'env', 'ini', 'cfg'].includes(ext);
};
const isImageFile = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext);
};
/** Route cache keyed by workspace and directory for warm file-surface returns. */
const filesRouteCache = createSurfaceCache<FileEntry[]>();
// eslint-disable-next-line react-refresh/only-export-components -- test-only reset for the module-scoped route cache
export function resetFilesRouteCache(): void {
filesRouteCache.resetForTests();
}
/* ── Props ── */
interface FilesAppProps {
workspaceId: string;
workspaceName?: string;
storageType?: StorageType;
/**
* Phase B.1: when provided, renders a workspace rail on the left and
* lets the user switch which workspace's files are shown without
* leaving the Files app. Omit to keep single-workspace mode.
*/
workspaces?: Workspace[];
onSelectWorkspace?: (workspaceId: string) => void;
onContextRail?: (target: { type: 'file'; id: string; label: string }) => void;
}
const FilesApp = ({
workspaceId,
workspaceName,
storageType = 'virtual',
workspaces,
onSelectWorkspace,
onContextRail,
}: FilesAppProps) => {
const { toast } = useToast();
const [currentPath, setCurrentPath] = useState('/');
const routeKey = surfaceCacheKey(['files', workspaceId, currentPath]);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [files, setFiles] = useState<FileEntry[]>(() => filesRouteCache.read(routeKey) ?? []);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [showSearch, setShowSearch] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file?: FileEntry } | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [creating, setCreating] = useState<'file' | 'folder' | null>(null);
const [newName, setNewName] = useState('');
const [loading, setLoading] = useState(() => !filesRouteCache.hasResolved(routeKey));
const [offline, setOffline] = useState(false);
// P7/D15 B4 (review): `files.length` conflates "never loaded", "loaded-empty",
// and "stale data from another dir". Track WHICH path the cached `files`
// actually belong to so error / empty / cached-banner stay mutually exclusive
// regardless of stale cross-directory data. null until the first success.
const [loadedKey, setLoadedKey] = useState<string | null>(() => (
filesRouteCache.hasResolved(routeKey) ? routeKey : null
));
const [clipboard, setClipboard] = useState<{ files: FileEntry[]; operation: 'copy' | 'cut' } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [previewFile, setPreviewFile] = useState<FileEntry | null>(null);
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [propertiesFile, setPropertiesFile] = useState<FileEntry | null>(null);
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [breadcrumbDropTarget, setBreadcrumbDropTarget] = useState<string | null>(null);
const [crossWorkspaceCopying, setCrossWorkspaceCopying] = useState<string | null>(null);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const internalDragPaths = useRef<string[]>([]);
const routeKeyRef = useRef(routeKey);
routeKeyRef.current = routeKey;
const storageMeta = STORAGE_LABELS[storageType];
const selectedFileObjects = useMemo(() => files.filter(f => selectedFiles.has(f.path)), [files, selectedFiles]);
const selectedFileCount = selectedFiles.size;
const selectedTotalSize = useMemo(() => selectedFileObjects.reduce((sum, f) => sum + (f.size || 0), 0), [selectedFileObjects]);
const breadcrumbs = useMemo(() => {
const parts = currentPath.split('/').filter(Boolean);
const crumbs = [{ label: 'Root', path: '/' }];
parts.forEach((part, i) => {
crumbs.push({ label: part, path: '/' + parts.slice(0, i + 1).join('/') });
});
return crumbs;
}, [currentPath]);
const visibleFiles = useMemo(() => {
let filtered = files.filter(f => {
const parentPath = f.path.substring(0, f.path.lastIndexOf('/')) || '/';
return parentPath === currentPath;
});
if (searchQuery) {
filtered = filtered.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase()));
}
return filtered.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [files, currentPath, searchQuery]);
const treeDirs = useMemo(() => files.filter(f => f.type === 'directory').sort((a, b) => a.path.localeCompare(b.path)), [files]);
/* ── Data fetching ── */
const refreshFiles = useCallback(async () => {
const requestKey = routeKey;
setLoading(true);
try {
const result = await adapter.listFiles(workspaceId, currentPath);
filesRouteCache.write(requestKey, result);
if (routeKeyRef.current === requestKey) {
setFiles(result);
setLoadedKey(requestKey);
setOffline(false);
}
} catch {
// A cold-nav failure keeps loadedKey on the old route, so stale entries
// cannot be presented as the current directory.
if (routeKeyRef.current === requestKey) setOffline(true);
} finally {
if (routeKeyRef.current === requestKey) setLoading(false);
}
}, [workspaceId, currentPath, routeKey]);
// Switching workspace or directory reseeds from its own cache before the
// refresh resolves, and clears selection so paths cannot leak across routes.
useEffect(() => {
const cached = filesRouteCache.read(routeKey);
const resolved = filesRouteCache.hasResolved(routeKey);
setFiles(cached ?? []);
setLoadedKey(resolved ? routeKey : null);
setLoading(!resolved);
setOffline(false);
setSelectedFiles(new Set());
}, [routeKey]);
// Keep optimistic file operations warm for a later remount, including a
// genuinely empty directory.
useEffect(() => {
if (loadedKey === routeKey && !loading) filesRouteCache.write(routeKey, files);
}, [files, loadedKey, loading, routeKey]);
// True when the cached `files` belong to the current workspace+directory.
const haveCurrentData = loadedKey === routeKey;
useEffect(() => { refreshFiles(); }, [refreshFiles]);
// C2 (UX-Northstar): chat artifact cards deep-link here with a file path —
// navigate to its directory, select it, and open the preview. The path
// originates from an agent tool-call input, so it is UNTRUSTED: normalize
// lexically (strip ../.) before use so a traversal payload can't set a
// bogus currentPath/selection. The backend file store is the authoritative
// containment guard; this is defense-in-depth.
useEffect(() => {
const dl = consumeDeepLink('files');
if (!dl?.path) return;
const filePath = normalizeWorkspacePath(dl.path);
if (filePath === '/') return; // path resolved to root → nothing to preselect
const parent = filePath.substring(0, filePath.lastIndexOf('/')) || '/';
const name = filePath.split('/').pop() ?? filePath;
setCurrentPath(parent);
setSelectedFiles(new Set([filePath]));
if (isPreviewable(name)) {
void openPreview({ name, path: filePath, type: 'file' } as FileEntry);
}
// Mount-only: the stash is read-and-clear; deps would replay a consumed link.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── Navigation ── */
const handleNavigate = (path: string) => {
setCurrentPath(path);
setSelectedFiles(new Set());
setContextMenu(null);
};
const goUp = () => {
const parent = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
handleNavigate(parent);
};
/* ── File operations ── */
const openPreview = async (file: FileEntry) => {
setPreviewFile(file);
if (isImageFile(file.name)) {
setPreviewContent(null);
} else {
setPreviewLoading(true);
try {
const blob = await adapter.downloadFile(workspaceId, file.path);
const text = await blob.text();
setPreviewContent(text);
} catch {
setPreviewContent(`# ${file.name}\n\nUnable to load file preview. The file may not be accessible or the backend may be offline.`);
} finally {
setPreviewLoading(false);
}
}
};
const handleFileClick = (file: FileEntry) => {
if (file.type === 'directory') {
handleNavigate(file.path);
} else if (isPreviewable(file.name)) {
openPreview(file);
setSelectedFiles(new Set([file.path]));
} else {
setSelectedFiles(new Set([file.path]));
}
if (file.type !== 'directory' && onContextRail) {
onContextRail({ type: 'file', id: file.path, label: file.name });
}
};
const handleFileSelect = (file: FileEntry, multi: boolean) => {
setSelectedFiles(prev => {
const next = new Set(multi ? prev : []);
if (next.has(file.path)) next.delete(file.path);
else next.add(file.path);
return next;
});
};
const handleContextMenu = (e: React.MouseEvent, file?: FileEntry) => {
e.stopPropagation();
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, file });
};
const handleUpload = async (uploadFiles: FileList | null) => {
if (!uploadFiles) return;
for (const file of Array.from(uploadFiles)) {
try {
const uploaded = await adapter.uploadFile(workspaceId, currentPath, file);
setFiles(prev => [
...prev.filter(existing => existing.path !== uploaded.path),
uploaded,
]);
} catch {
toast({
title: 'Upload failed',
description: `${file.name} could not be uploaded. Check the file service and try again.`,
variant: 'destructive',
});
}
}
};
const handleCreateFolder = () => {
if (!newName.trim()) return;
const path = `${currentPath === '/' ? '' : currentPath}/${newName.trim()}`;
setFiles(prev => [...prev, { name: newName.trim(), path, type: 'directory', modifiedAt: new Date().toISOString() }]);
adapter.createDirectory(workspaceId, path).catch(() => toast({ title: 'Failed to create folder', variant: 'destructive' }));
setCreating(null);
setNewName('');
};
const handleDelete = (file: FileEntry) => {
setFiles(prev => prev.filter(f => f.path !== file.path && !f.path.startsWith(file.path + '/')));
adapter.deleteFile(workspaceId, file.path).catch(() => toast({ title: 'Failed to delete file', variant: 'destructive' }));
setContextMenu(null);
};
const handleRename = (file: FileEntry) => {
if (!renameValue.trim() || renameValue === file.name) { setRenaming(null); return; }
const newPath = file.path.replace(file.name, renameValue.trim());
setFiles(prev => prev.map(f => f.path === file.path ? { ...f, name: renameValue.trim(), path: newPath } : f));
adapter.moveFile(workspaceId, file.path, newPath).catch(() => toast({ title: 'Failed to move file', variant: 'destructive' }));
setRenaming(null);
};
const handleDownload = (file: FileEntry) => {
adapter.downloadFile(workspaceId, file.path).catch(() => {});
setContextMenu(null);
};
const handlePaste = async () => {
if (!clipboard) return;
for (const file of clipboard.files) {
const destPath = `${currentPath === '/' ? '' : currentPath}/${file.name}`;
if (clipboard.operation === 'copy') {
await adapter.copyFile(workspaceId, file.path, destPath);
} else {
await adapter.moveFile(workspaceId, file.path, destPath);
}
}
setClipboard(null);
refreshFiles();
};
const handleBulkDelete = () => {
setFiles(prev => prev.filter(f => !selectedFiles.has(f.path) && !Array.from(selectedFiles).some(s => f.path.startsWith(s + '/'))));
selectedFiles.forEach(path => adapter.deleteFile(workspaceId, path).catch(() => {}));
setSelectedFiles(new Set());
};
const handleBulkCopy = () => { setClipboard({ files: selectedFileObjects, operation: 'copy' }); setSelectedFiles(new Set()); };
const handleBulkCut = () => { setClipboard({ files: selectedFileObjects, operation: 'cut' }); setSelectedFiles(new Set()); };
const handleBulkDownload = () => { selectedFileObjects.filter(f => f.type === 'file').forEach(f => { adapter.downloadFile(workspaceId, f.path).catch(() => {}); }); };
const handleBulkMove = (destPath: string) => {
selectedFileObjects.forEach(f => {
const newPath = `${destPath === '/' ? '' : destPath}/${f.name}`;
adapter.moveFile(workspaceId, f.path, newPath).catch(() => {});
setFiles(prev => prev.map(pf => pf.path === f.path ? { ...pf, path: newPath } : pf));
});
setSelectedFiles(new Set());
setShowMoveDialog(false);
};
const handleSelectAll = () => { setSelectedFiles(new Set(visibleFiles.map(f => f.path))); };
const handleCrossWorkspaceCopy = async (targetWorkspaceId: string) => {
if (crossWorkspaceCopying) return;
const paths = [...internalDragPaths.current];
internalDragPaths.current = [];
const filesToCopy = files.filter(file => paths.includes(file.path) && file.type === 'file');
if (filesToCopy.length === 0) {
toast({
title: 'Select files to copy',
description: 'Cross-workspace copy supports files. Move folders within a workspace instead.',
variant: 'destructive',
});
return;
}
setCrossWorkspaceCopying(targetWorkspaceId);
try {
const results = await Promise.allSettled(filesToCopy.map(file =>
adapter.copyFileBetweenWorkspaces(workspaceId, targetWorkspaceId, file.path, `/${file.name}`),
));
const copied = results.filter(result => result.status === 'fulfilled').length;
const failed = results.length - copied;
const targetName = workspaces?.find(workspace => workspace.id === targetWorkspaceId)?.name ?? targetWorkspaceId;
if (failed === 0) {
toast({ title: `${copied} file${copied === 1 ? '' : 's'} copied`, description: `Copied to ${targetName} at the workspace root.` });
} else {
toast({
title: `${copied} copied, ${failed} failed`,
description: `Some files could not be copied to ${targetName}. Check the target workspace and try again.`,
variant: 'destructive',
});
}
} finally {
setCrossWorkspaceCopying(null);
}
};
/* ── Keyboard shortcuts ── */
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 'a') { e.preventDefault(); handleSelectAll(); return; }
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedFiles.size > 0) { e.preventDefault(); handleBulkDelete(); return; }
if (mod && e.key === 'c' && selectedFiles.size > 0) { e.preventDefault(); handleBulkCopy(); return; }
if (mod && e.key === 'x' && selectedFiles.size > 0) { e.preventDefault(); handleBulkCut(); return; }
if (mod && e.key === 'v' && clipboard) { e.preventDefault(); handlePaste(); return; }
if (mod && e.key === 'd' && selectedFiles.size > 0) { e.preventDefault(); handleBulkDownload(); return; }
if (e.key === 'Escape') {
if (previewFile) { setPreviewFile(null); setPreviewContent(null); }
else if (propertiesFile) setPropertiesFile(null);
else if (showMoveDialog) setShowMoveDialog(false);
else if (selectedFiles.size > 0) setSelectedFiles(new Set());
return;
}
if (e.key === 'F2' && selectedFiles.size === 1) {
e.preventDefault();
const path = Array.from(selectedFiles)[0];
const file = files.find(f => f.path === path);
if (file) { setRenaming(file.path); setRenameValue(file.name); }
return;
}
if (e.key === 'Enter' && selectedFiles.size === 1) {
const path = Array.from(selectedFiles)[0];
const file = files.find(f => f.path === path);
if (file) handleFileClick(file);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedFiles, clipboard, files, previewFile, propertiesFile, showMoveDialog, visibleFiles]);
/* ── Drag & drop (external files) ── */
const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; if (e.dataTransfer.types.includes('Files')) setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; if (dragCounter.current === 0) setIsDragging(false); };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounter.current = 0; if (e.dataTransfer.files?.length) handleUpload(e.dataTransfer.files); };
/* ── Breadcrumb drop ── */
const onBreadcrumbDragOver = (e: React.DragEvent, crumbPath: string) => {
if (internalDragPaths.current.length > 0) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; setBreadcrumbDropTarget(crumbPath); }
};
const onBreadcrumbDragLeave = () => setBreadcrumbDropTarget(null);
const onBreadcrumbDrop = (e: React.DragEvent, crumbPath: string) => {
e.preventDefault(); e.stopPropagation(); setBreadcrumbDropTarget(null);
const paths = internalDragPaths.current;
if (paths.length === 0) return;
const filesToMove = files.filter(f => paths.includes(f.path));
filesToMove.forEach(f => {
const newPath = `${crumbPath === '/' ? '' : crumbPath}/${f.name}`;
if (newPath !== f.path) {
adapter.moveFile(workspaceId, f.path, newPath).catch(() => {});
setFiles(prev => prev.map(pf => pf.path === f.path ? { ...pf, path: newPath } : pf));
}
});
setSelectedFiles(new Set());
internalDragPaths.current = [];
};
/* ── Internal row drag helpers ── */
const startInternalDrag = (e: React.DragEvent, file: FileEntry) => {
const paths = selectedFiles.has(file.path) && selectedFiles.size > 1 ? Array.from(selectedFiles) : [file.path];
internalDragPaths.current = paths;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', paths.join(','));
};
const endInternalDrag = () => { internalDragPaths.current = []; };
/* ── Render ── */
return (
<div
className="flex h-full bg-background/50 relative"
onClick={() => setContextMenu(null)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Degraded-cache banner — only honest when the cache is for THIS dir.
P7/D15 B4: a cold-nav failure leaves stale files from another directory,
so gate on haveCurrentData (not files.length) to avoid a "showing cached
files" banner over a directory that never loaded. */}
{offline && haveCurrentData && files.length > 0 && (
<div className="absolute top-0 left-0 right-0 z-10 px-4 py-2 text-xs text-center" style={{ backgroundColor: 'var(--hive-800)', borderBottom: '1px solid var(--hive-700)', color: 'var(--honey-500)' }}>
Server unreachable showing cached files. <button onClick={refreshFiles} className="underline ml-1">Retry</button>
</div>
)}
{/* Drag overlay */}
<AnimatePresence>
{isDragging && <FileUploadZone currentPath={currentPath} />}
</AnimatePresence>
{/* Phase B.1: workspace rail — shown when caller provides workspaces.
Lets the user switch workspaces without leaving the Files app. */}
{workspaces && workspaces.length > 0 && onSelectWorkspace && (
<WorkspaceRail
workspaces={workspaces}
activeWorkspaceId={workspaceId}
onSelect={onSelectWorkspace}
onDropFiles={handleCrossWorkspaceCopy}
/>
)}
{/* Tree sidebar */}
<FileTree
treeDirs={treeDirs}
currentPath={currentPath}
workspaceName={workspaceName}
storageType={storageType}
onNavigate={handleNavigate}
/>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Toolbar */}
<FileActions
currentPath={currentPath}
storageType={storageType}
breadcrumbs={breadcrumbs}
viewMode={viewMode}
loading={loading}
showSearch={showSearch}
searchQuery={searchQuery}
onGoUp={goUp}
onRefresh={refreshFiles}
onNavigate={handleNavigate}
onSetViewMode={setViewMode}
onSetShowSearch={setShowSearch}
onSetSearchQuery={setSearchQuery}
onCreateFolder={() => setCreating('folder')}
fileInputRef={fileInputRef}
onBreadcrumbDragOver={onBreadcrumbDragOver}
onBreadcrumbDragLeave={onBreadcrumbDragLeave}
onBreadcrumbDrop={onBreadcrumbDrop}
breadcrumbDropTarget={breadcrumbDropTarget}
/>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
{/* New folder input */}
<AnimatePresence>
{creating === 'folder' && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="px-3 border-b border-border/20 overflow-hidden">
<div className="flex items-center gap-2 py-1.5">
<Folder className="w-4 h-4" style={{ color: 'var(--honey)' }} />
<input
aria-label="New folder name"
name="newFolderName"
autoComplete="off"
spellCheck={false}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') { setCreating(null); setNewName(''); } }}
placeholder="New folder name..."
className="flex-1 bg-transparent text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
autoFocus
/>
<button onClick={handleCreateFolder} className="text-[11px] px-2 py-0.5 rounded bg-primary text-primary-foreground">Create</button>
<button onClick={() => { setCreating(null); setNewName(''); }} className="text-[11px] px-2 py-0.5 rounded text-muted-foreground hover:text-foreground">Cancel</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* File list/grid */}
<div
className="flex-1 overflow-auto p-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
role="region"
aria-label={`Files in ${workspaceName || workspaceId}`}
tabIndex={0}
onContextMenu={e => handleContextMenu(e)}
>
{loading && !haveCurrentData ? (
/* P7/D15 B4: in-flight cold load (no data for this dir yet) — not empty. */
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2" data-testid="files-loading">
<Loader2 className="w-8 h-8 opacity-40 animate-spin" />
<p className="text-xs">Loading files</p>
</div>
) : offline && !haveCurrentData ? (
/* P7/D15 B4 (review): cold-load FAILURE for THIS dir (we never loaded
it — loadedKey still points elsewhere or is null). A real error,
never the stale "empty directory"/"cached files" lie. A failed
REFRESH of an already-loaded dir keeps haveCurrentData true and
falls through to its cache instead of this branch. */
<div role="alert" className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<AlertTriangle className="w-8 h-8 text-destructive/60" />
<p className="text-xs text-foreground">Couldn't load files</p>
<p className="text-[11px] max-w-xs text-center">The file service is unreachable — this is a load error, not an empty folder.</p>
<button onClick={refreshFiles} className="text-[11px] px-3 py-1 rounded-lg bg-primary/10 text-honey hover:bg-primary/20 transition-colors">
Retry
</button>
</div>
) : visibleFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Folder className="w-10 h-10 opacity-30" />
<p className="text-xs">Empty directory</p>
<button onClick={() => fileInputRef.current?.click()} className="text-[11px] px-3 py-1 rounded-lg bg-primary/10 text-honey hover:bg-primary/20 transition-colors">
Upload files
</button>
</div>
) : viewMode === 'list' ? (
<table className="w-full">
<thead>
<tr className="text-[11px] text-muted-foreground border-b border-border/20">
<th className="text-left font-normal pb-1 pl-1">Name</th>
{/* D11 (no-fabrication): the file store carries no creator/
provenance field (core FileEntry = name/path/size/modified/
isDirectory), so every row honestly reads "—". When the
backend later attaches an authored-by source, render it
here ("made by Claude Code", "you uploaded") — never invent. */}
<th className="text-left font-normal pb-1 w-32">Source</th>
<th className="text-right font-normal pb-1 w-20">Size</th>
<th className="text-right font-normal pb-1 w-28 pr-1">Modified</th>
</tr>
</thead>
<tbody>
{visibleFiles.map(file => {
const Icon = file.type === 'directory' ? Folder : getFileIcon(file.name);
const isSelected = selectedFiles.has(file.path);
return (
<tr
key={file.path}
draggable
onDragStart={e => startInternalDrag(e, file)}
onDragEnd={endInternalDrag}
onClick={e => {
if (e.ctrlKey || e.metaKey) handleFileSelect(file, true);
else handleFileClick(file);
}}
onDoubleClick={() => file.type === 'directory' && handleNavigate(file.path)}
onContextMenu={e => handleContextMenu(e, file)}
className={`group text-xs cursor-pointer transition-colors ${isSelected ? 'bg-primary/15' : 'hover:bg-muted/30'}`}
>
<td className="py-1 pl-1 flex items-center gap-2">
<Icon className={`w-4 h-4 ${file.type === 'directory' ? '' : 'text-muted-foreground'}`} style={file.type === 'directory' ? { color: 'var(--honey)' } : undefined} />
{renaming === file.path ? (
<input
aria-label={`Rename ${file.name}`}
name="fileRename"
autoComplete="off"
spellCheck={false}
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRename(file); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file)}
className="bg-muted/50 rounded px-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<span className="truncate">{file.name}</span>
)}
</td>
{/* D11: no provenance in the file metadata \u2192 honest "\u2014". */}
<td className="py-1 text-left text-muted-foreground/60 text-[11px]" data-testid="file-source">{'\u2014'}</td>
<td className="py-1 text-right text-muted-foreground text-[11px]">{file.type === 'file' ? formatSize(file.size) : '\u2014'}</td>
<td className="py-1 text-right text-muted-foreground text-[11px] pr-1">{file.modifiedAt ? new Date(file.modifiedAt).toLocaleDateString(DATE_LOCALE) : '\u2014'}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="grid grid-cols-4 gap-2">
{visibleFiles.map(file => {
const Icon = file.type === 'directory' ? Folder : getFileIcon(file.name);
const isSelected = selectedFiles.has(file.path);
return (
<button
key={file.path}
draggable
onDragStart={e => startInternalDrag(e, file)}
onDragEnd={endInternalDrag}
onClick={e => {
if (e.ctrlKey || e.metaKey) handleFileSelect(file, true);
else handleFileClick(file);
}}
onDoubleClick={() => file.type === 'directory' && handleNavigate(file.path)}
onContextMenu={e => handleContextMenu(e, file)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl transition-colors ${isSelected ? 'bg-primary/15 border border-primary/30' : 'hover:bg-muted/30 border border-transparent'}`}
>
<Icon className={`w-8 h-8 ${file.type === 'directory' ? '' : 'text-muted-foreground'}`} style={file.type === 'directory' ? { color: 'var(--honey)' } : undefined} />
<span className="text-[11px] text-foreground truncate w-full text-center">{file.name}</span>
{file.type === 'file' && <span className="text-[11px] text-muted-foreground">{formatSize(file.size)}</span>}
</button>
);
})}
</div>
)}
</div>
{/* Bulk actions toolbar */}
<AnimatePresence>
{selectedFileCount > 1 && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }} className="overflow-hidden border-t border-primary/20">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-primary/5">
<div className="flex items-center gap-1.5 mr-2">
<CheckSquare className="w-3.5 h-3.5 text-honey" />
<span className="text-[11px] font-medium text-honey">{selectedFileCount} selected</span>
<span className="text-[11px] text-muted-foreground">({formatSize(selectedTotalSize)})</span>
</div>
<div className="h-4 w-px bg-border/30" />
<HintTooltip content="Download selected files">
<button onClick={handleBulkDownload} className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-foreground hover:bg-muted/50 transition-colors"><Download className="w-3 h-3" /> Download</button>
</HintTooltip>
<HintTooltip content="Copy selected">
<button onClick={handleBulkCopy} className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-foreground hover:bg-muted/50 transition-colors"><Copy className="w-3 h-3" /> Copy</button>
</HintTooltip>
<HintTooltip content="Cut selected">
<button onClick={handleBulkCut} className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-foreground hover:bg-muted/50 transition-colors"><Scissors className="w-3 h-3" /> Cut</button>
</HintTooltip>
<HintTooltip content="Move selected to folder">
<button onClick={() => setShowMoveDialog(true)} className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-foreground hover:bg-muted/50 transition-colors"><FolderInput className="w-3 h-3" /> Move</button>
</HintTooltip>
<div className="h-4 w-px bg-border/30" />
<HintTooltip content="Delete selected">
<button onClick={handleBulkDelete} className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-destructive hover:bg-destructive/10 transition-colors"><Trash2 className="w-3 h-3" /> Delete</button>
</HintTooltip>
<div className="flex-1" />
<button onClick={handleSelectAll} className="text-[11px] text-muted-foreground hover:text-foreground transition-colors">Select all</button>
<button onClick={() => setSelectedFiles(new Set())} className="flex items-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"><XSquare className="w-3 h-3" /> Clear</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Status bar */}
<div className="flex items-center justify-between px-3 py-1 border-t border-border/20 text-[11px] text-muted-foreground">
<span>{visibleFiles.length} items{selectedFiles.size > 0 && ` \u00b7 ${selectedFiles.size} selected`}</span>
<span className="flex items-center gap-1">
<storageMeta.icon className={`w-3 h-3 ${storageMeta.color}`} />
{storageMeta.label}
</span>
</div>
</div>
{/* Move dialog */}
<AnimatePresence>
{showMoveDialog && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowMoveDialog(false)}>
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }} className="w-[300px] bg-background border border-border/40 rounded-2xl shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-4 py-3 border-b border-border/20 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Move {selectedFileCount} items to...</h3>
<button type="button" aria-label="Close move dialog" onClick={() => setShowMoveDialog(false)} className="p-0.5 rounded hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><XIcon className="w-4 h-4 text-muted-foreground" /></button>
</div>
<div className="p-2 max-h-[250px] overflow-auto space-y-0.5">
<button onClick={() => handleBulkMove('/')} className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs hover:bg-muted/50 transition-colors ${currentPath === '/' ? 'opacity-40 pointer-events-none' : ''}`}>
<Folder className="w-3.5 h-3.5 text-muted-foreground" /><span>Root</span>
</button>
{treeDirs.filter(d => !selectedFiles.has(d.path)).map(dir => (
<button key={dir.path} onClick={() => handleBulkMove(dir.path)} className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs hover:bg-muted/50 transition-colors ${dir.path === currentPath ? 'opacity-40 pointer-events-none' : ''}`}>
<Folder className="w-3.5 h-3.5" style={{ color: 'var(--honey)' }} />
<span>{dir.name}</span>
<span className="text-[11px] text-muted-foreground ml-auto font-mono">{dir.path}</span>
</button>
))}
{treeDirs.length === 0 && <p className="text-xs text-muted-foreground text-center py-4">No folders available</p>}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Preview panel */}
<AnimatePresence>
{previewFile && (
<FilePreview
file={previewFile}
content={previewContent}
loading={previewLoading}
isImage={isImageFile(previewFile.name)}
onClose={() => { setPreviewFile(null); setPreviewContent(null); }}
onDownload={handleDownload}
/>
)}
</AnimatePresence>
{/* Context menu */}
<AnimatePresence>
{contextMenu && (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} className="fixed z-[200] glass-strong rounded-xl shadow-2xl py-1 min-w-[160px]" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={e => e.stopPropagation()}>
{contextMenu.file ? (
<>
{contextMenu.file.type === 'file' && (
<button onClick={() => handleDownload(contextMenu.file!)} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Download className="w-3.5 h-3.5" /> Download</button>
)}
<button onClick={() => { setRenaming(contextMenu.file!.path); setRenameValue(contextMenu.file!.name); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Edit className="w-3.5 h-3.5" /> Rename</button>
<button onClick={() => { setClipboard({ files: [contextMenu.file!], operation: 'copy' }); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Copy className="w-3.5 h-3.5" /> Copy</button>
<button onClick={() => { setClipboard({ files: [contextMenu.file!], operation: 'cut' }); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Scissors className="w-3.5 h-3.5" /> Cut</button>
<button onClick={() => { setPropertiesFile(contextMenu.file!); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Info className="w-3.5 h-3.5" /> Properties</button>
<div className="h-px bg-border/30 my-0.5" />
<button onClick={() => handleDelete(contextMenu.file!)} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 transition-colors"><Trash2 className="w-3.5 h-3.5" /> Delete</button>
</>
) : (
<>
<button onClick={() => { setCreating('folder'); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><FolderPlus className="w-3.5 h-3.5" /> New Folder</button>
<button onClick={() => { fileInputRef.current?.click(); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><Upload className="w-3.5 h-3.5" /> Upload Files</button>
{clipboard && (
<button onClick={() => { handlePaste(); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><ClipboardPaste className="w-3.5 h-3.5" /> Paste</button>
)}
<button onClick={() => { refreshFiles(); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/50 transition-colors"><RefreshCw className="w-3.5 h-3.5" /> Refresh</button>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Properties dialog */}
<AnimatePresence>
{propertiesFile && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPropertiesFile(null)}>
<motion.div initial={{ scale: 0.9, opacity: 0, y: 20 }} animate={{ scale: 1, opacity: 1, y: 0 }} exit={{ scale: 0.9, opacity: 0, y: 20 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }} className="w-[360px] max-h-[80vh] bg-background border border-border/40 rounded-2xl shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 px-5 py-4 border-b border-border/20 bg-muted/20">
{(() => {
const Icon = propertiesFile.type === 'directory' ? Folder : getFileIcon(propertiesFile.name);
return <Icon className={`w-8 h-8 ${propertiesFile.type === 'directory' ? '' : 'text-honey'}`} style={propertiesFile.type === 'directory' ? { color: 'var(--honey)' } : undefined} />;
})()}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground truncate">{propertiesFile.name}</h3>
<p className="text-[11px] text-muted-foreground font-mono truncate">{propertiesFile.path}</p>
</div>
<button type="button" aria-label="Close file properties" onClick={() => setPropertiesFile(null)} className="p-1 rounded-lg hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><XIcon className="w-4 h-4 text-muted-foreground" /></button>
</div>
<div className="px-5 py-4 space-y-4 overflow-auto max-h-[60vh]">
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">General</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs"><FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Name</span><span className="text-foreground truncate flex-1">{propertiesFile.name}</span></div>
<div className="flex items-center gap-2 text-xs"><Hash className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Type</span><span className="text-foreground">{propertiesFile.type === 'directory' ? 'Directory' : (propertiesFile.mimeType || propertiesFile.name.split('.').pop()?.toUpperCase() + ' File' || 'File')}</span></div>
{propertiesFile.type === 'file' && (
<div className="flex items-center gap-2 text-xs"><HardDrive className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Size</span><span className="text-foreground">{formatSize(propertiesFile.size)}{propertiesFile.size ? ` (${propertiesFile.size.toLocaleString()} bytes)` : ''}</span></div>
)}
</div>
</div>
<div className="h-px bg-border/20" />
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Dates</h4>
<div className="space-y-2">
{propertiesFile.modifiedAt && <div className="flex items-center gap-2 text-xs"><Clock className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Modified</span><span className="text-foreground">{new Date(propertiesFile.modifiedAt).toLocaleString(DATE_LOCALE)}</span></div>}
{propertiesFile.createdAt && <div className="flex items-center gap-2 text-xs"><Clock className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Created</span><span className="text-foreground">{new Date(propertiesFile.createdAt).toLocaleString(DATE_LOCALE)}</span></div>}
{!propertiesFile.modifiedAt && !propertiesFile.createdAt && <p className="text-[11px] text-muted-foreground/60 italic">No date information available</p>}
</div>
</div>
<div className="h-px bg-border/20" />
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Storage</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs"><storageMeta.icon className={`w-3.5 h-3.5 ${storageMeta.color} shrink-0`} /><span className="text-muted-foreground w-20">Provider</span><span className="text-foreground">{storageMeta.label} Storage</span></div>
<div className="flex items-center gap-2 text-xs"><MapPin className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Path</span><span className="text-foreground font-mono text-[11px] truncate flex-1">{propertiesFile.path}</span></div>
<div className="flex items-center gap-2 text-xs"><Folder className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Workspace</span><span className="text-foreground">{workspaceName || workspaceId}</span></div>
</div>
</div>
<div className="h-px bg-border/20" />
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Permissions</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs"><Shield className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Access</span><span className="text-foreground">{storageType === 'team' ? 'Team (shared)' : storageType === 'local' ? 'Local (private)' : 'Virtual (session)'}</span></div>
<div className="flex items-center gap-2 text-xs">{storageType === 'team' ? <Unlock className="w-3.5 h-3.5 shrink-0" style={{ color: 'var(--healthy)' }} /> : <Lock className="w-3.5 h-3.5 shrink-0" style={{ color: 'var(--honey)' }} />}<span className="text-muted-foreground w-20">Visibility</span><span className="text-foreground">{storageType === 'team' ? 'Shared with team' : 'Only you'}</span></div>
<div className="flex items-center gap-2 text-xs"><Edit className="w-3.5 h-3.5 text-muted-foreground shrink-0" /><span className="text-muted-foreground w-20">Writable</span><span className="inline-flex items-center gap-1 text-[11px]" style={{ color: 'var(--healthy)' }}><span className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--healthy)' }} /> Yes</span></div>
</div>
</div>
{propertiesFile.type === 'file' && <VersionHistory workspaceId={workspaceId} fileName={propertiesFile.name} />}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border/20 bg-muted/10">
<button onClick={() => setPropertiesFile(null)} className="text-xs px-4 py-1.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">Close</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default FilesApp;

View File

@@ -0,0 +1,99 @@
/**
* FilesAppTabs — P16 three-tab layout wrapper around FilesApp.
*
* Adds a "Virtual | Local | Team" tab strip above the existing FilesApp
* shell. Each tab remounts FilesApp with a different `storageType`; the
* remount intentionally resets per-storage navigation state (currentPath,
* selection, preview) so flipping back to Virtual doesn't carry a local
* absolute path through.
*
* All FilesApp props pass through unchanged except `storageType`, which
* becomes the tab state instead of a caller-provided default.
*/
import { useState } from 'react';
import type { StorageType, Workspace } from '@/lib/types';
import { STORAGE_LABELS } from './files/file-utils';
import { FILES_TAB_ORDER, initialTabFor } from './files/files-tabs';
import FilesApp from './FilesApp';
interface FilesAppTabsProps {
workspaceId: string;
workspaceName?: string;
/**
* The workspace's configured default storageType. Used to pick which
* tab opens first; the user can switch freely from there.
*/
defaultStorageType?: StorageType;
workspaces?: Workspace[];
onSelectWorkspace?: (workspaceId: string) => void;
onContextRail?: (target: { type: 'file'; id: string; label: string }) => void;
}
const FilesAppTabs = ({
workspaceId,
workspaceName,
defaultStorageType,
workspaces,
onSelectWorkspace,
onContextRail,
}: FilesAppTabsProps) => {
const [activeTab, setActiveTab] = useState<StorageType>(() =>
initialTabFor(defaultStorageType),
);
return (
<div className="flex flex-col h-full">
{/* Tab strip */}
<div
role="tablist"
aria-label="Storage location"
className="flex items-center gap-1 px-2 pt-2 border-b border-border/20 shrink-0"
>
{FILES_TAB_ORDER.map(tab => {
const meta = STORAGE_LABELS[tab];
const Icon = meta.icon;
const active = activeTab === tab;
return (
<button
key={tab}
type="button"
role="tab"
aria-selected={active}
aria-controls={`files-tab-panel-${tab}`}
data-testid={`files-tab-${tab}`}
onClick={() => setActiveTab(tab)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-display rounded-t-md border border-b-0 transition-colors ${
active
? `bg-background ${meta.color} border-border/40`
: 'bg-transparent text-muted-foreground border-transparent hover:text-foreground hover:bg-muted/20'
}`}
>
<Icon className="w-3 h-3" />
{meta.label}
</button>
);
})}
</div>
{/* Active panel — key-based remount resets per-tab FilesApp state */}
<div
id={`files-tab-panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`files-tab-${activeTab}`}
className="flex-1 min-h-0"
>
<FilesApp
key={`${workspaceId}:${activeTab}`}
workspaceId={workspaceId}
workspaceName={workspaceName}
storageType={activeTab}
workspaces={workspaces}
onSelectWorkspace={onSelectWorkspace}
onContextRail={onContextRail}
/>
</div>
</div>
);
};
export default FilesAppTabs;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
/**
* Lane SR — home day-story scroll-reveal (Path-to-9 Pillar 1.1 entrance).
*
* Locks the reveal contract:
* - Motion allowed + IntersectionObserver present: a section starts hidden
* (opacity 0, data-reveal="pending") and, on entering the viewport, plays the
* shared `card-enter` rise/fade (data-reveal="in") exactly once per visit —
* the observer disconnects on first intersection and scrolling back up (a
* later non-intersecting callback) never re-hides it.
* - Reduced motion: visible from first paint, no hidden state, no animation,
* and NO observer is ever created (REDUCED: rise/fade off).
* - No IntersectionObserver (jsdom default / very old engines): the same
* visible-always passthrough — content is never gated behind an observer that
* can't fire.
*
* framer-motion's `useReducedMotion` caches globally, so it's mocked to a
* hoisted toggle (the real `motion` primitives are untouched here anyway).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
const h = vi.hoisted(() => ({ reduce: false }));
vi.mock('framer-motion', async () => {
const actual = await vi.importActual<typeof import('framer-motion')>('framer-motion');
return { ...actual, useReducedMotion: () => h.reduce };
});
import { RevealSection } from './HomeReveal';
// ── Mock IntersectionObserver — records instances so a test can fire entries ──
class MockIO {
static instances: MockIO[] = [];
callback: IntersectionObserverCallback;
observed: Element[] = [];
disconnected = false;
root: Element | null = null;
rootMargin = '';
thresholds: ReadonlyArray<number> = [];
constructor(cb: IntersectionObserverCallback) {
this.callback = cb;
MockIO.instances.push(this);
}
observe(el: Element) { this.observed.push(el); }
unobserve() {}
disconnect() { this.disconnected = true; }
takeRecords(): IntersectionObserverEntry[] { return []; }
fire(isIntersecting: boolean) {
act(() => {
this.callback(
[{ isIntersecting, target: this.observed[0] } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
});
}
}
const ORIGINAL_IO = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver;
function setIO(ctor: unknown) {
(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = ctor;
}
beforeEach(() => {
h.reduce = false;
MockIO.instances = [];
setIO(MockIO);
});
afterEach(() => {
cleanup();
setIO(ORIGINAL_IO);
});
function wrapper() {
return screen.getByTestId('reveal-child').parentElement as HTMLElement;
}
describe('RevealSection (Lane SR)', () => {
it('always renders its children', () => {
render(<RevealSection><p data-testid="reveal-child">Day story</p></RevealSection>);
expect(screen.getByText('Day story')).toBeInTheDocument();
});
it('starts hidden and rises/fades in on first intersection — card-enter, once per visit', () => {
render(<RevealSection><p data-testid="reveal-child">Start here</p></RevealSection>);
// Pending: invisible, marked, and an observer is watching the wrapper.
const el = wrapper();
expect(el.getAttribute('data-reveal')).toBe('pending');
expect(el.style.opacity).toBe('0');
expect(MockIO.instances).toHaveLength(1);
const io = MockIO.instances[0];
expect(io.observed[0]).toBe(el);
// Enters the viewport → reveals with the shared card-enter rise/fade.
io.fire(true);
expect(el.getAttribute('data-reveal')).toBe('in');
expect(el.style.animation).toContain('card-enter');
expect(el.style.opacity).not.toBe('0');
// Once per visit: the observer is torn down on first intersection.
expect(io.disconnected).toBe(true);
});
it('does NOT re-hide when scrolled back up (a later non-intersecting entry)', () => {
render(<RevealSection><p data-testid="reveal-child">Pick up</p></RevealSection>);
const io = MockIO.instances[0];
io.fire(true);
expect(wrapper().getAttribute('data-reveal')).toBe('in');
// Scroll-up: the observer would report isIntersecting=false — reveal holds.
io.fire(false);
expect(wrapper().getAttribute('data-reveal')).toBe('in');
expect(wrapper().style.animation).toContain('card-enter');
});
it('reduced motion → visible immediately, no animation, no observer created', () => {
h.reduce = true;
render(<RevealSection><p data-testid="reveal-child">While you slept</p></RevealSection>);
const el = wrapper();
expect(el.getAttribute('data-reveal')).toBeNull();
expect(el.style.opacity).toBe('');
expect(el.style.animation).toBe('');
expect(MockIO.instances).toHaveLength(0);
});
it('no IntersectionObserver → visible immediately (passthrough, no observer)', () => {
setIO(undefined);
render(<RevealSection><p data-testid="reveal-child">Up next</p></RevealSection>);
const el = wrapper();
expect(el.getAttribute('data-reveal')).toBeNull();
expect(el.style.opacity).toBe('');
expect(screen.getByText('Up next')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,92 @@
/**
* Lane SR — home day-story scroll-reveal (Path-to-9 Pillar 1.1 entrance).
*
* Each day-story section (Start here → memory-review → While you slept →
* pick-up → suggests → up next) rises 8px + fades in as it scrolls into view,
* reusing the shipped `card-enter` keyframe (8px rise + fade, --mo-ease) — so
* the scripted Home scroll reads as one designed entrance, not a static page.
*
* Contract (spec Lane SR):
* - Once per section per visit: the IntersectionObserver disconnects on the
* first intersection and `revealed` never resets, so scrolling back up never
* replays the entrance. A visit = a mount; a new route entry replays it.
* - Reduced motion (REDUCED: rise/fade off): sections are visible from the
* first paint with no hidden pre-state and no animation.
* - No IntersectionObserver (jsdom / very old engines): the same visible-always
* fallback — content is never gated behind an observer that can't fire.
*/
import {
useEffect, useRef, useState, type ReactNode, type CSSProperties, type RefObject,
} from 'react';
import { useReducedMotion } from 'framer-motion';
interface ScrollReveal<T extends HTMLElement> {
ref: RefObject<T | null>;
/** Whether the entrance has fired (true from first paint when not animating). */
revealed: boolean;
/** True only when we should play the rise/fade (motion allowed + observable). */
animate: boolean;
}
function useScrollReveal<T extends HTMLElement>(): ScrollReveal<T> {
const reduce = useReducedMotion();
const ref = useRef<T | null>(null);
const canObserve = typeof IntersectionObserver !== 'undefined';
// Visible from the first paint under reduced motion or without an observer;
// otherwise start hidden and let the observer reveal it on entry.
const [revealed, setRevealed] = useState(() => !canObserve);
useEffect(() => {
if (reduce || !canObserve) {
setRevealed(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setRevealed(true);
io.disconnect(); // once per section per visit — no scroll-up replay
}
},
{ root: null, rootMargin: '0px 0px -8% 0px', threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [reduce, canObserve]);
return { ref, revealed, animate: !reduce && canObserve };
}
interface RevealSectionProps {
children: ReactNode;
/** Optional class on the reveal wrapper (kept minimal — section spacing stays
* on the child so its margins collapse through this transparent wrapper). */
className?: string;
}
/**
* Wraps ONE day-story section. Pending → invisible; on scroll-in → the shared
* `card-enter` rise/fade (backwards fill, so no transform lingers to shift a
* portaled menu after settle). Under reduced motion / no observer it is a plain
* always-visible passthrough.
*/
export function RevealSection({ children, className }: RevealSectionProps): ReactNode {
const { ref, revealed, animate } = useScrollReveal<HTMLDivElement>();
const style: CSSProperties | undefined = animate
? revealed
? { animation: 'card-enter var(--mo-slow) var(--mo-ease) backwards' }
: { opacity: 0 }
: undefined;
return (
<div
ref={ref}
className={className}
style={style}
data-reveal={animate ? (revealed ? 'in' : 'pending') : undefined}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,582 @@
/**
* Warm-Hive PR6b (B1) — LauncherApp A/B segmented toggle.
*
* Variation A · Launch = the existing live detect/launch UI (preserved).
* Variation B · How memory is shared = a pure-UI explainer (flow diagram +
* three cards + an explicitly-labelled provenance example, no live data).
*
* These tests assert the toggle wiring and that Variation B carries the
* static explainer content WITHOUT fabricating a live recall.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
adapter: {
detectTools: vi.fn(),
getToolProcesses: vi.fn(),
launchTool: vi.fn(),
manageHooks: vi.fn(),
killTool: vi.fn(),
runExternalToolTask: vi.fn(),
streamToolOutput: vi.fn(() => () => {}),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
import LauncherApp, { resetLauncherRouteCache } from './LauncherApp';
const detectionResp = {
platform: 'darwin',
detectedAt: '2026-06-18T00:00:00.000Z',
tools: [
{
id: 'claude-code',
displayName: 'Claude Code',
installed: true,
installedPath: '/opt/homebrew/bin/claude',
version: '2.4.0',
hooksInstalled: true,
hookPointerPath: '/home/.claude/hooks',
},
],
};
beforeEach(() => {
resetLauncherRouteCache();
mocks.adapter.detectTools.mockResolvedValue(detectionResp);
mocks.adapter.getToolProcesses.mockResolvedValue({ processes: [] });
mocks.adapter.launchTool.mockResolvedValue({ ok: true, pid: 123 });
mocks.adapter.manageHooks.mockResolvedValue({ ok: true });
mocks.adapter.killTool.mockResolvedValue({ ok: true, pid: 123, reason: 'SIGTERM' });
mocks.adapter.runExternalToolTask.mockResolvedValue({
roomId: 'room-1',
runs: [{ runId: 'run-1', workspaceId: 'ws-1', status: 'queued', statusUrl: '/api/agent-runs/run-1' }],
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('LauncherApp · A/B toggle', () => {
it('repaints detected tools on return and refreshes silently', async () => {
const first = render(<LauncherApp />);
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
const baseline = mocks.adapter.detectTools.mock.calls.length;
first.unmount();
render(<LauncherApp />);
expect(screen.getByText('Claude Code')).toBeInTheDocument();
await waitFor(() => expect(mocks.adapter.detectTools.mock.calls.length).toBeGreaterThan(baseline));
});
it('defaults to Variation A and renders the live launch UI', async () => {
render(<LauncherApp />);
// The detected tool from the live adapter appears (Variation A).
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
expect(screen.getByText(/optional prompt/i)).toBeInTheDocument();
// Toggle exists with both tabs, A selected.
const launchTab = screen.getByRole('tab', { name: /^launch$/i });
const memTab = screen.getByRole('tab', { name: /how memory is shared/i });
expect(launchTab).toHaveAttribute('aria-selected', 'true');
expect(memTab).toHaveAttribute('aria-selected', 'false');
});
it('switches to Variation B and renders the explainer (flow + cards), hiding the live UI', async () => {
render(<LauncherApp />);
await screen.findByText('Claude Code');
fireEvent.click(screen.getByRole('tab', { name: /how memory is shared/i }));
expect(screen.getByRole('tab', { name: /how memory is shared/i })).toHaveAttribute('aria-selected', 'true');
// Explainer heading + flow nodes.
expect(screen.getByText(/how a launched agent shares the hive/i)).toBeInTheDocument();
expect(screen.getByText('The hive')).toBeInTheDocument();
expect(screen.getByText('hive-mind')).toBeInTheDocument();
// Three explainer cards.
expect(screen.getByText(/it recalls on start/i)).toBeInTheDocument();
expect(screen.getByText(/it commits as it works/i)).toBeInTheDocument();
expect(screen.getByText(/reversible & local/i)).toBeInTheDocument();
// The live launch UI (prompt box) is no longer mounted.
expect(screen.queryByText(/optional prompt/i)).not.toBeInTheDocument();
});
it('labels the provenance example as an example (no fabricated live recall)', async () => {
render(<LauncherApp />);
await screen.findByText('Claude Code');
fireEvent.click(screen.getByRole('tab', { name: /how memory is shared/i }));
const prov = screen.getByText(/remembered from Claude Code/i);
expect(prov).toHaveTextContent(/^example ·/i);
});
it('toggles back to Variation A and restores the live UI', async () => {
render(<LauncherApp />);
await screen.findByText('Claude Code');
fireEvent.click(screen.getByRole('tab', { name: /how memory is shared/i }));
expect(screen.queryByText(/optional prompt/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /^launch$/i }));
expect(screen.getByRole('tab', { name: /^launch$/i })).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(screen.getByText(/optional prompt/i)).toBeInTheDocument());
expect(screen.getByText('Claude Code')).toBeInTheDocument();
});
});
describe('LauncherApp · live output (#4)', () => {
it('reveals the output pane when a running observed tool badge is clicked', async () => {
mocks.adapter.getToolProcesses.mockResolvedValue({
processes: [
{ pid: 4242, toolId: 'claude-code', startedAt: '2026-06-30T00:00:00Z', observed: true },
],
});
render(<LauncherApp activeWorkspaceId="ws1" />);
const badge = await screen.findByRole('button', { name: /running/i });
fireEvent.click(badge);
expect(await screen.findByText(/Waiting for output/i)).toBeInTheDocument();
expect(mocks.adapter.streamToolOutput).toHaveBeenCalledWith(4242, expect.any(Object));
});
});
describe('LauncherApp · captured tasks', () => {
it('fans Codex and Hermes into two workspaces and opens their shared Room', async () => {
const onOpenRoom = vi.fn();
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-07-11T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex CLI',
installed: true,
installedPath: '/usr/local/bin/codex',
version: '0.144.1',
hooksInstalled: true,
hookPointerPath: '/home/.codex/hooks',
capabilities: {
interactiveLaunch: true,
headlessTask: true,
structuredProgress: true,
resumable: true,
liveWaggleDance: false,
},
permissionModes: ['read-only', 'workspace-write', 'native'],
},
{
id: 'hermes',
displayName: 'Hermes',
installed: true,
installedPath: '/usr/local/bin/hermes',
version: '1.0.0',
hooksInstalled: true,
hookPointerPath: '/home/.hermes/hooks',
capabilities: {
interactiveLaunch: true,
headlessTask: true,
structuredProgress: true,
resumable: true,
liveWaggleDance: true,
},
permissionModes: ['native'],
},
{
id: 'cursor',
displayName: 'Cursor',
installed: true,
installedPath: '/Applications/Cursor.app',
version: '1.0.0',
hooksInstalled: true,
hookPointerPath: '/home/.cursor/hooks',
capabilities: {
interactiveLaunch: true,
headlessTask: false,
structuredProgress: false,
resumable: false,
liveWaggleDance: false,
},
permissionModes: [],
},
{
id: 'openclaw',
displayName: 'OpenClaw',
installed: false,
installedPath: null,
version: null,
hooksInstalled: false,
hookPointerPath: null,
capabilities: {
interactiveLaunch: true,
headlessTask: true,
structuredProgress: true,
resumable: true,
liveWaggleDance: true,
},
permissionModes: ['native'],
},
],
});
mocks.adapter.runExternalToolTask.mockResolvedValue({
roomId: 'room-multi',
runs: [
{ runId: 'codex-a', toolId: 'codex', workspaceId: 'ws-a', status: 'queued', statusUrl: '/api/agent-runs/codex-a' },
{ runId: 'codex-b', toolId: 'codex', workspaceId: 'ws-b', status: 'queued', statusUrl: '/api/agent-runs/codex-b' },
{ runId: 'hermes-a', toolId: 'hermes', workspaceId: 'ws-a', status: 'queued', statusUrl: '/api/agent-runs/hermes-a' },
{ runId: 'hermes-b', toolId: 'hermes', workspaceId: 'ws-b', status: 'queued', statusUrl: '/api/agent-runs/hermes-b' },
{ runId: 'synthesis', toolId: 'hermes', workspaceId: 'ws-b', status: 'queued', statusUrl: '/api/agent-runs/synthesis' },
],
});
render(
<LauncherApp
activeWorkspaceId="ws-a"
workspaces={[
{ id: 'ws-a', name: 'Alpha' },
{ id: 'ws-b', name: 'Beta' },
]}
onOpenRoom={onOpenRoom}
/>,
);
const codexCard = await screen.findByTestId('launcher-tool-codex');
fireEvent.click(within(codexCard).getByRole('button', { name: /run task/i }));
expect(screen.getByRole('checkbox', { name: 'Codex CLI' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Hermes' })).not.toBeChecked();
expect(screen.queryByRole('checkbox', { name: 'Cursor' })).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: 'OpenClaw' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Hermes' }));
fireEvent.change(screen.getByRole('combobox', { name: 'Access for Codex CLI' }), {
target: { value: 'workspace-write' },
});
expect(screen.getByRole('combobox', { name: 'Access for Hermes' })).toHaveValue('native');
expect(screen.getByRole('checkbox', { name: 'Alpha' })).toBeChecked();
fireEvent.click(screen.getByRole('checkbox', { name: 'Beta' }));
fireEvent.change(screen.getByLabelText('Task for Codex CLI'), {
target: { value: ' Compare both implementations ' },
});
fireEvent.click(screen.getByRole('button', { name: /start in room/i }));
await waitFor(() => expect(mocks.adapter.runExternalToolTask).toHaveBeenCalledWith({
participants: [
{ toolId: 'codex', access: 'workspace-write' },
{ toolId: 'hermes', access: 'native' },
],
workspaceIds: ['ws-a', 'ws-b'],
prompt: 'Compare both implementations',
}));
expect(screen.getByText('Started 2 agents across 2 workspaces (5 worker runs) — opening Room.')).toBeInTheDocument();
expect(onOpenRoom).toHaveBeenCalledWith('room-multi');
});
it('does not offer a captured task for a GUI-only tool', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-07-11T00:00:00.000Z',
tools: [{
id: 'cursor',
displayName: 'Cursor',
installed: true,
installedPath: '/Applications/Cursor.app',
version: '1.0.0',
hooksInstalled: true,
hookPointerPath: '/home/.cursor/hooks',
capabilities: {
interactiveLaunch: true,
headlessTask: false,
structuredProgress: false,
resumable: false,
liveWaggleDance: false,
},
permissionModes: [],
}],
});
render(<LauncherApp workspaces={[{ id: 'ws-a', name: 'Alpha' }]} />);
expect(await screen.findByText('Cursor')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^launch$/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /run task/i })).not.toBeInTheDocument();
});
});
describe('LauncherApp · hook cohort (#3)', () => {
it('offers hook actions for every real-hook tool (e.g. codex), not just claude-code', async () => {
// The 7 real-hook tools (claude-code, claude-desktop, codex,
// codex-desktop, cursor, hermes, openclaw) all ship a bin — the dock
// must expose hook install for each, mirroring the backend HOOKS_COHORT.
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-06-29T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex',
installed: true,
installedPath: '/usr/local/bin/codex',
version: '1.0.0',
hooksInstalled: false,
hookPointerPath: null,
},
],
});
render(<LauncherApp />);
expect(await screen.findByText('Codex')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /install hooks/i })).toBeInTheDocument();
});
it('offers launch for a detected third-party adapter and sends its raw prompt', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'linux',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'foo-cli',
displayName: 'Foo CLI',
installed: true,
installedPath: '/usr/local/bin/foo',
version: '1.0.0',
hooksInstalled: false,
hookPointerPath: null,
launchable: true,
hookCapable: false,
builtin: false,
acceptsInlinePrompt: true,
},
],
});
render(<LauncherApp activeWorkspaceId="ws-adapter" />);
expect(await screen.findByText('Foo CLI')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/optional launch prompt/i), {
target: { value: 'summarize adapter context' },
});
expect(screen.getByText(/Sent to:/i).parentElement).toHaveTextContent(/Foo CLI/i);
fireEvent.click(screen.getByRole('button', { name: /^launch$/i }));
await waitFor(() => {
expect(mocks.adapter.launchTool).toHaveBeenCalledWith(
expect.objectContaining({
id: 'foo-cli',
installedPath: '/usr/local/bin/foo',
workspaceId: 'ws-adapter',
prompt: 'summarize adapter context',
}),
);
});
expect(screen.queryByRole('button', { name: /install hooks/i })).not.toBeInTheDocument();
expect(screen.queryByText(/launch and hook management arrive in Phase 4/i)).not.toBeInTheDocument();
});
it('shows recovery copy instead of launch controls when a detected install cannot launch', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'win32',
detectedAt: '2026-07-09T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex',
installed: true,
installedPath:
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.623.19656.0_x64__2p2nqsd0c76g0\\app\\resources\\codex.exe',
version: null,
hooksInstalled: false,
hookPointerPath: null,
launchable: false,
hookCapable: true,
diagnostic:
'Codex was found in WindowsApps, but Windows blocks command-line launch from that app alias. Install a PATH CLI build of Codex or launch Codex from Start, then refresh.',
},
],
});
render(<LauncherApp />);
expect(await screen.findByText('Codex')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^launch$/i })).not.toBeInTheDocument();
expect(screen.getByText(/Windows blocks command-line launch/i)).toBeInTheDocument();
expect(screen.getByText(/Launch is blocked for this install/i)).toBeInTheDocument();
expect(screen.queryByText(/adapter is not configured for launch/i)).not.toBeInTheDocument();
});
it('shows actionable hook install stdout details such as the backup path', async () => {
mocks.adapter.detectTools.mockResolvedValue({
...detectionResp,
tools: [
{
...detectionResp.tools[0],
hooksInstalled: false,
hookPointerPath: null,
},
],
});
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: true,
action: 'install',
stdout: 'settings backed up: /home/.claude/settings.json.hive-mind-backup.2026-07-08T19-00-00Z\ninstall complete',
stderr: '',
code: 0,
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /install hooks/i }));
expect(await screen.findByText(/Claude Code: install OK/i)).toBeInTheDocument();
expect(screen.getByText('Backup')).toBeInTheDocument();
expect(screen.getByText(/settings\.json\.hive-mind-backup/i)).toBeInTheDocument();
expect(screen.getByText('Recovery')).toBeInTheDocument();
expect(screen.queryByText(/^stdout:/i)).not.toBeInTheDocument();
});
it('shows hook failure stderr even when the route returns a generic error', async () => {
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: false,
action: 'verify',
stdout: '',
stderr: 'missing hook pointer: /home/.claude/settings.json',
code: 1,
error: 'verify failed',
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /^verify$/i }));
expect(await screen.findByText(/verify failed/i)).toBeInTheDocument();
expect(screen.getByText(/missing hook pointer/i)).toBeInTheDocument();
expect(screen.getByText(/settings\.json/i)).toBeInTheDocument();
});
it('summarizes long hook output instead of flooding the result panel', async () => {
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: false,
action: 'verify',
stdout: '',
stderr: [
'failure detail 1: missing hook pointer',
'failure detail 2: stale backup file',
'failure detail 3: cli not trusted',
'failure detail 4: config mismatch',
'failure detail 5: lifecycle skipped',
'failure detail 6: retry recommended',
'failure detail 7: noisy internal trace',
'failure detail 8: noisy internal trace',
].join('\n'),
code: 1,
error: 'verify failed',
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /^verify$/i }));
expect(await screen.findByText(/verify failed/i)).toBeInTheDocument();
expect(screen.getByText(/failure detail 1/i)).toBeInTheDocument();
expect(screen.getByText('More output')).toBeInTheDocument();
expect(screen.getByText(/2 additional hook output lines hidden/i)).toBeInTheDocument();
expect(screen.queryByText(/failure detail 8/i)).not.toBeInTheDocument();
expect(screen.getByText('Recovery')).toBeInTheDocument();
});
it('shows recovery guidance when hook verify fails without stdout or stderr', async () => {
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: false,
action: 'verify',
stdout: '',
stderr: '',
code: 1,
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /^verify$/i }));
expect(await screen.findByText(/verify failed \(exit 1\)/i)).toBeInTheDocument();
expect(screen.getByText(/no hook output was returned/i)).toBeInTheDocument();
expect(screen.getByText(/reinstall hooks/i)).toBeInTheDocument();
});
it('summarizes hook verify check failures instead of showing raw check output', async () => {
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: false,
action: 'verify',
stdout: [
'hive-mind/codex-hooks: verify',
' [PASS] hooks.json exists — /home/.codex/hooks.json',
' [FAIL] hook command trusted — manual approval required in Codex settings',
'One or more checks failed.',
].join('\n'),
stderr: '',
code: 1,
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /^verify$/i }));
expect(await screen.findByText(/verify failed \(exit 1\)/i)).toBeInTheDocument();
expect(screen.getByText('Check failed')).toBeInTheDocument();
expect(screen.getByText(/manual approval required in Codex settings/i)).toBeInTheDocument();
expect(screen.getByText('Recovery')).toBeInTheDocument();
expect(screen.queryByText(/\[FAIL\]/)).not.toBeInTheDocument();
});
it('labels hook uninstall restore and cleanup details without implying install state', async () => {
mocks.adapter.detectTools.mockResolvedValue({
...detectionResp,
tools: [
{
...detectionResp.tools[0],
hooksInstalled: true,
hookPointerPath: '/home/.codex/hive-mind-install.json',
},
],
});
mocks.adapter.manageHooks.mockResolvedValueOnce({
ok: true,
action: 'uninstall',
stdout: [
'hive-mind/codex-hooks: uninstall',
' - hooks.json: /home/.codex/hooks.json',
' - restored from: /home/.codex/hooks.json.hive-mind-backup.2026-07-08T19-00-00Z',
' - created removed: no',
' - backup removed: yes',
' - pointer removed: yes',
'Done. hooks.json is byte-identical to pre-install state.',
].join('\n'),
stderr: '',
code: 0,
});
render(<LauncherApp />);
fireEvent.click(await screen.findByRole('button', { name: /uninstall hooks/i }));
expect(await screen.findByText(/Claude Code: uninstall OK/i)).toBeInTheDocument();
expect(screen.getByText('Changed file')).toBeInTheDocument();
expect(screen.getByText('Restored from')).toBeInTheDocument();
expect(screen.getByText('Backup removed')).toBeInTheDocument();
expect(screen.getByText('Pointer removed')).toBeInTheDocument();
expect(screen.queryByText('Install pointer')).not.toBeInTheDocument();
});
it('explains that Claude Desktop is launch-only because hooks are not supported yet', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-07-08T00:00:00.000Z',
tools: [
{
id: 'claude-desktop',
displayName: 'Claude Desktop',
installed: true,
installedPath: '/Applications/Claude.app',
version: '1.2.3',
hooksInstalled: false,
hookPointerPath: null,
},
],
});
render(<LauncherApp />);
expect(await screen.findByText('Claude Desktop')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^launch$/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install hooks/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^verify$/i })).not.toBeInTheDocument();
expect(screen.getByText(/launch only/i)).toBeInTheDocument();
expect(screen.getByText(/hooks are not supported for Claude Desktop yet/i)).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,437 @@
/**
* MCPHubApp — standalone MCP Hub (UX-Refactor Phase 4B, S08; PRD §12.8 "MCPs
* are powerful but always visible, scoped, auditable, and reversible").
*
* Tabs: Installed · Catalog · Custom · Remote Registry · Activity.
* - Installed: live instances from GET /api/mcps (state badges, start/stop,
* C21 test with the live/static mode label, C19 scope editor, revoke with
* a scope-and-consequence confirm). Logs = honest coming-soon (no route).
* - Catalog: the static registry grid (McpCatalog) wired to the real
* installer — PRO+ (B5; 403 routes through the UpgradeModal) and the
* SecurityGate "risk approval required" path renders the ApprovalModal
* (HIGH findings can be force-overridden; CRITICAL never).
* - Custom: register an arbitrary stdio server (PRO+ server-side).
* - Remote Registry: C20 deferred — static catalog pointers only, the
* runtime is stdio-only in v1.
*
* Note on the PRD §12.8 five-tab vocabulary: the "Marketplace" tab is
* deliberately NOT duplicated here — Phase 4A deferred the marketplace
* consolidation to the single S21 surface (MarketplaceApp), which the dock's
* Extend zone exposes next to this hub.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ExternalLink, Loader2, Server } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { useService } from '@/providers/ServiceProvider';
import { useToast } from '@/hooks/use-toast';
import { useRevalidateOnError } from '@/hooks/useRevalidateOnError';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
import { actionRisk } from '@/lib/risk-display';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { createSurfaceCache } from '@/lib/surface-cache';
import McpCatalog from './connectors/McpCatalog';
import InstalledMcpList from './mcp/InstalledMcpList';
import AddCustomMcpForm from './mcp/AddCustomMcpForm';
import McpScopeDialog from './mcp/McpScopeDialog';
import InstallAuditPanel from './extend/InstallAuditPanel';
import type { McpListItem } from './mcp/mcp-hub-types';
type HubTab = 'installed' | 'catalog' | 'custom' | 'remote' | 'activity';
const TAB_LABELS: Record<HubTab, string> = {
installed: 'Installed',
catalog: 'Catalog',
custom: 'Custom',
remote: 'Remote Registry',
activity: 'Activity',
};
const TAB_HINTS: Record<HubTab, string> = {
installed: 'Servers registered on this machine — running state, scope, test, revoke',
catalog: 'Curated MCP catalog — install routes through the security-scanned marketplace installer',
custom: 'Register your own local stdio MCP server',
remote: 'Remote registries are reference links in v1 — the runtime is stdio-only',
activity: 'MCP install / revoke history from the shared audit trail',
};
/** Keep installed MCP state visible while the hub revalidates on return. */
const mcpRouteCache = createSurfaceCache<McpListItem[]>();
const MCP_CACHE_KEY = 'installed';
// eslint-disable-next-line react-refresh/only-export-components -- test-only cache reset
export function resetMcpRouteCache(): void {
mcpRouteCache.resetForTests();
}
interface PendingRiskApproval {
id: string;
severity?: string;
message?: string;
}
interface MCPHubAppProps {
/** Active workspace's persona id — drives the catalog recommendation tile. */
personaId?: string;
}
const MCPHubApp = ({ personaId }: MCPHubAppProps = {}) => {
// Cold-load race guard (the HomeCockpit lesson): wait for the adapter's
// initial connect() to settle before firing authed calls.
const { connecting } = useService();
const { toast } = useToast();
const [tab, setTab] = useState<HubTab>('installed');
const cachedItems = mcpRouteCache.read(MCP_CACHE_KEY);
const [items, setItems] = useState<McpListItem[]>(cachedItems ?? []);
const [loading, setLoading] = useState(() => !mcpRouteCache.hasResolved(MCP_CACHE_KEY));
const [error, setError] = useState<string | null>(null);
const [installingId, setInstallingId] = useState<string | null>(null);
const [installNotice, setInstallNotice] = useState<string | null>(null);
const [pendingApproval, setPendingApproval] = useState<PendingRiskApproval | null>(null);
const [revokeTarget, setRevokeTarget] = useState<McpListItem | null>(null);
const [revoking, setRevoking] = useState(false);
const [revokeNotice, setRevokeNotice] = useState<string | null>(null);
const [scopeTarget, setScopeTarget] = useState<McpListItem | null>(null);
const [scoping, setScoping] = useState(false);
// A4 honest install affordance: POST /api/mcps/install resolves the catalog
// id against marketplace package NAMES (waggle_install_type='mcp') — only
// ~9 of the 148 catalog ids resolve today, the rest 404. Fetch the
// resolvable set once and render Install only where the path exists; the
// copy-command strip stays as the path for everything else.
const [resolvableMcpNames, setResolvableMcpNames] = useState<ReadonlySet<string>>(new Set());
const [resolvableErrored, setResolvableErrored] = useState(false);
const load = useCallback(async () => {
if (!mcpRouteCache.hasResolved(MCP_CACHE_KEY)) setLoading(true);
setError(null);
try {
const mcps = await adapter.getMcps();
const next = mcps as McpListItem[];
setItems(next);
mcpRouteCache.write(MCP_CACHE_KEY, next);
} catch (err) {
if (!mcpRouteCache.hasResolved(MCP_CACHE_KEY)) setItems([]);
setError(err instanceof Error ? err.message : 'Server unreachable');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (connecting) return;
void load();
}, [connecting, load]);
// Review fix: connect settlement fires both the [connecting] effect and the
// revalidation listener — single-flight the fetch.
const resolvableInFlight = useRef(false);
const loadResolvableNames = useCallback(() => {
if (resolvableInFlight.current) return;
resolvableInFlight.current = true;
adapter.getMarketplace({ type: 'mcp', limit: 200 })
.then(r => {
setResolvableMcpNames(
new Set(((r.packages ?? []) as Array<{ name?: string }>).map(p => p.name ?? '')),
);
setResolvableErrored(false);
})
// Unknown registry (server down) → no Install buttons; the copy-command
// fallback still works and installs would 404/fail anyway. P1b D3
// plus-clause: flagged errored so the empty Set is no longer cached as
// valid for the session — revalidates on focus/online/connect-settled.
.catch(() => { setResolvableMcpNames(new Set()); setResolvableErrored(true); })
.finally(() => { resolvableInFlight.current = false; });
}, []);
useEffect(() => {
if (connecting) return;
loadResolvableNames();
}, [connecting, loadResolvableNames]);
useRevalidateOnError(resolvableErrored, loadResolvableNames);
const installed = items.filter(i => i.installed);
const installedIds = new Set(installed.map(i => i.id));
// Full-screen spinner only on the INITIAL load — background refreshes
// (start/stop/test/install/scope) keep the current tab mounted so per-row
// state (C21 test results, catalog search/category) survives the reload
// instead of being wiped by an unmount.
const initialLoading = loading && items.length === 0;
/** B5 PRO+ install via the marketplace installer; `force` = approved HIGH
* override. The block that actually fires lives INSIDE installer.install()
* and is only overridable via `forceInsecure` (the audited override path);
* `force` alone just means "reinstall" — retrying with it loops the
* ApprovalModal forever. */
const handleInstall = async (id: string, force = false) => {
setInstallingId(id);
setInstallNotice(null);
try {
const res = await adapter.installMcp(id, force ? { force: true, forceInsecure: true } : undefined) as {
installed?: boolean; server?: string; status?: string; startError?: string;
requiresApproval?: boolean; error?: string; message?: string;
required?: string; actual?: string;
// Installer-level 422 blocks nest the scan; route-level 403 blocks
// are top-level { blocked, severity, message } with NO scanResult.
blocked?: boolean; severity?: string;
scanResult?: { overall_severity?: string; blocked?: boolean };
};
if (res.installed) {
toast({
title: 'MCP server installed',
description: res.startError
? `${res.server ?? id} installed but failed to start: ${res.startError}`
: `${res.server ?? id} — status: ${res.status ?? 'registered'}`,
...(res.startError ? { variant: 'destructive' as const } : {}),
});
await load();
return;
}
if (res.error === 'TIER_INSUFFICIENT') {
// The adapter's global 403 handler routes this through the
// UpgradeModal; the explicit dispatch keeps the house pattern (and
// the unit-testable contract) — the event is idempotent.
window.dispatchEvent(new CustomEvent('waggle:tier-insufficient', {
detail: {
required: res.required ?? 'TEAMS',
actual: res.actual ?? 'FREE',
message: `Installing MCP servers needs the Team plan or an active trial.`,
},
}));
return;
}
if (res.requiresApproval) {
// Cover BOTH block envelopes: installer-level (scanResult.overall_severity)
// and route-level (top-level severity) — a route-level CRITICAL must
// never fall into the overridable-HIGH modal.
const severity = res.scanResult?.overall_severity ?? res.severity;
if (severity === 'CRITICAL') {
// CRITICAL is always blocked server-side — no override path exists;
// offering an Approve button would be a lie.
setInstallNotice(`Install blocked: the security scan found CRITICAL issues in "${id}". CRITICAL blocks cannot be overridden.`);
} else {
setPendingApproval({ id, severity, message: res.message });
}
return;
}
setInstallNotice(res.message ?? res.error ?? `Install of "${id}" failed`);
} catch (err) {
setInstallNotice(err instanceof Error ? err.message : 'Install failed — server unreachable');
} finally {
setInstallingId(null);
}
};
const approvalRequest: ApprovalRequest | null = pendingApproval ? {
action: `Install "${pendingApproval.id}" despite security-scan findings?`,
scope: [
`Security scan severity: ${pendingApproval.severity ?? 'HIGH'}`,
...(pendingApproval.message ? [pendingApproval.message] : []),
'The override is recorded in the install audit trail',
],
riskLevel: actionRisk('mcp-install-override'),
} : null;
const revokeRequest: ApprovalRequest | null = revokeTarget ? {
action: `Revoke MCP server "${revokeTarget.name}"?`,
scope: [
'Stops the running process (if any)',
'Removes the server from the persisted config — it will not restart with Waggle',
'Writes a revoke entry to the install audit trail',
],
riskLevel: actionRisk('mcp-revoke'),
} : null;
const handleRevoke = async () => {
if (!revokeTarget) return;
const target = revokeTarget;
setRevoking(true);
try {
const res = await adapter.revokeMcp(target.id) as {
ok?: boolean; stoppedInstance?: boolean; removedConfig?: boolean; error?: string;
};
if (res.ok) {
setRevokeNotice(
`Revoked ${target.name} — process ${res.stoppedInstance ? 'stopped' : 'was not running'}, `
+ `persisted config ${res.removedConfig ? 'removed' : 'not found'}.`,
);
} else {
setRevokeNotice(res.error ?? `Revoke of "${target.name}" failed`);
}
await load();
} catch (err) {
toast({ title: 'Revoke failed', description: err instanceof Error ? err.message : 'Server unreachable', variant: 'destructive' });
} finally {
setRevoking(false);
setRevokeTarget(null);
}
};
const handleScope = async (payload: { scope: 'personal' } | { workspaceId: string }) => {
if (!scopeTarget) return;
setScoping(true);
try {
const res = await adapter.updateMcpPermissions(scopeTarget.id, payload);
if (!res.ok) {
// 400/404 error bodies parse as data (adapter.fetch never throws on
// HTTP errors) — keep the dialog open and surface the rejection
// instead of closing as if the save succeeded.
toast({ title: 'Scope update failed', description: res.error ?? 'The server rejected the scope change', variant: 'destructive' });
return;
}
await load();
setScopeTarget(null);
} catch (err) {
toast({ title: 'Scope update failed', description: err instanceof Error ? err.message : 'Server unreachable', variant: 'destructive' });
} finally {
setScoping(false);
}
};
return (
<div className="h-full overflow-auto p-4">
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-display font-semibold text-foreground">MCP Hub</h2>
<span className="text-[11px] text-muted-foreground ml-auto flex items-center gap-1.5">
{loading && items.length > 0 && (
<Loader2 className="w-3 h-3 animate-spin" aria-label="Refreshing" />
)}
{installed.length} installed · {items.length} in catalog
</span>
</div>
{/* All tabs stay in the Tab order (FilesAppTabs pattern) — a roving
tabIndex without arrow-key handling makes every inactive tab
keyboard-unreachable (WCAG 2.1.1). */}
<div className="flex gap-1 mb-4 p-0.5 rounded-lg bg-muted/50 w-fit flex-wrap" role="tablist" aria-label="MCP Hub sections">
{(Object.keys(TAB_LABELS) as HubTab[]).map(t => (
<HintTooltip key={t} content={TAB_HINTS[t]}>
<button
onClick={() => setTab(t)}
role="tab"
aria-selected={tab === t}
className={`px-3 py-1.5 text-xs rounded-md font-display transition-colors ${
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{TAB_LABELS[t]}
{t === 'installed' && installed.length > 0 && <span className="ml-1 text-[10px] opacity-80">({installed.length})</span>}
</button>
</HintTooltip>
))}
</div>
{installNotice && (
<p role="alert" data-testid="mcp-install-notice" className="mb-3 text-[11px] text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-2.5 py-1.5">
{installNotice}
</p>
)}
{revokeNotice && (
<p role="status" data-testid="mcp-revoke-notice" className="mb-3 text-[11px] text-foreground bg-muted/40 border border-border/30 rounded-lg px-2.5 py-1.5">
{revokeNotice}
</p>
)}
{initialLoading && (
<div className="flex items-center justify-center py-12" role="status" aria-live="polite">
<Loader2 className="w-6 h-6 text-honey animate-spin" />
</div>
)}
{!initialLoading && error && (
<div role="alert" className="text-center py-8">
<p className="text-xs text-destructive mb-2">{error}</p>
<button onClick={() => void load()} className="text-xs text-honey hover:underline">Retry</button>
</div>
)}
{!initialLoading && !error && tab === 'installed' && (
<InstalledMcpList
items={installed}
onRevoke={setRevokeTarget}
onScope={setScopeTarget}
onChanged={() => void load()}
/>
)}
{!initialLoading && !error && tab === 'catalog' && (
<McpCatalog
personaId={personaId}
installedIds={installedIds}
installableIds={resolvableMcpNames}
installingId={installingId}
onInstall={(id) => void handleInstall(id)}
/>
)}
{!initialLoading && !error && tab === 'custom' && (
<AddCustomMcpForm onAdded={(id) => {
toast({ title: 'Custom MCP server added', description: `${id} registered` });
void load();
}} />
)}
{tab === 'remote' && (
<div className="max-w-lg space-y-3" data-testid="mcp-remote-registry">
<h3 className="text-sm font-display font-semibold text-foreground">Remote registries</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Waggle&rsquo;s MCP runtime is <strong>stdio-only</strong> in this release remote/hosted MCP
transports are not supported yet, so there is nothing to install from a remote registry
honestly. Browse these registries for servers, then install the local (stdio) variant from
the Catalog tab or add it as a Custom server.
</p>
<ul className="space-y-1.5 text-xs">
{[
{ name: 'Official MCP reference servers', url: 'https://github.com/modelcontextprotocol/servers' },
{ name: 'awesome-mcp-servers', url: 'https://github.com/punkpeye/awesome-mcp-servers' },
{ name: 'Composio MCP registry', url: 'https://mcp.composio.dev' },
].map(link => (
<li key={link.url}>
<a href={link.url} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-honey hover:text-honey/80">
<ExternalLink className="w-3 h-3" /> {link.name}
</a>
</li>
))}
</ul>
</div>
)}
{tab === 'activity' && (
<InstallAuditPanel type="mcp" limit={25} />
)}
{/* SecurityGate "risk approval required" → explicit approve/deny (HIGH only) */}
<ApprovalModal
request={approvalRequest}
approveLabel="Install anyway"
busy={installingId !== null}
onApprove={() => {
const id = pendingApproval?.id;
setPendingApproval(null);
if (id) void handleInstall(id, true);
}}
onCancel={() => setPendingApproval(null)}
/>
{/* Revoke confirm — reversibility with consequences (PRD §12.8) */}
<ApprovalModal
request={revokeRequest}
approveLabel="Revoke server"
busy={revoking}
onApprove={() => void handleRevoke()}
onCancel={() => setRevokeTarget(null)}
/>
{/* C19 scope editor */}
<McpScopeDialog
serverId={scopeTarget?.id ?? null}
currentWorkspaceId={scopeTarget?.connectedTo?.[0]}
busy={scoping}
onSubmit={(payload) => void handleScope(payload)}
onClose={() => setScopeTarget(null)}
/>
</div>
);
};
export default MCPHubApp;

View File

@@ -0,0 +1,580 @@
/**
* MarketplaceApp — the Warm-Hive Marketplace surface (PR4 Variation A; screen
* 09). "Skills + connectors + MCP as one shelf, agent-searchable." The four
* shelves (D2) — All / Skills / Connectors / MCP — federate AT READ; agents,
* models and templates keep their dedicated hubs (deep-linked from elsewhere).
*
* Installs are one-click + type-aware (D3, §1): the ExtensionCard drives
* Add / Connect / Enable through the shared install store, so installing in
* ANY view reflects in ALL (the count bar + inline chat). Security is
* preserved server-side — a SecurityGate block surfaces as a destructive
* toast, tier routes to Upgrade — and the destructive Remove direction keeps
* its ApprovalModal consequence dialog. The Audit tab is the C18 shared feed.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Store, Loader2, Package, Sparkles } from 'lucide-react';
import type { ExtensionType } from '@waggle/shared';
import { classifyInstallRisk, actionRisk, installTrustSource } from '@/lib/risk-display';
import { adapter } from '@/lib/adapter';
import { createSurfaceCache, surfaceCacheKey } from '@/lib/surface-cache';
import { useService } from '@/providers/ServiceProvider';
import { useInstallStore } from '@/providers/InstallProvider';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
import { Skeleton } from '@/components/ui/skeleton';
import { dedupePacks } from '@/lib/dedupe-packs';
import {
filterExtensions, sortExtensions, dedupeExtensions,
fromConnector, fromMarketplacePackage, fromMcpCatalogRow, fromSkillPack,
type Extension, type MarketplacePackageRow, type McpCatalogRow,
} from '@/lib/extension-catalog';
import ExtensionCard from './extend/ExtensionCard';
import InstallAuditPanel from './extend/InstallAuditPanel';
import AgentSearchBox, { type AutoMatchState } from './extend/AgentSearchBox';
import InstallFromUrlRow from './extend/InstallFromUrlRow';
/** The four shelves (D2) — the design's "one simple shelf" set. */
const SHELVES = ['all', 'skill', 'connector', 'mcp'] as const;
type Facet = (typeof SHELVES)[number];
type Tab = 'browse' | 'audit';
const FACET_LABELS: Record<Facet, string> = {
all: 'All',
skill: 'Skills',
connector: 'Connectors',
mcp: 'MCPs',
};
/**
* Pillar 2.6 route-cache: returning to Marketplace within the session repaints
* the last-loaded shelf + list instantly and refreshes silently (no
* re-skeleton). `listCache` keys the extensions payload by [facet, query];
* `facetCache` remembers which shelf was active so the return lands on it.
*/
const listCache = createSurfaceCache<Extension[]>();
const facetCache = createSurfaceCache<Facet>();
const FACET_SLOT = 'active';
// eslint-disable-next-line react-refresh/only-export-components -- test-only reset for the module-scoped route cache (mirrors clearMemoryListCache)
export function resetMarketplaceRouteCache(): void {
listCache.resetForTests();
facetCache.resetForTests();
}
/** Honest in-place note for the connectable/enableable shelves (D3). */
const SHELF_NOTES: Partial<Record<Facet, string>> = {
connector: 'Connect with an API token here — it goes straight to your vault. OAuth connectors open in the Connector Hub.',
mcp: 'Enable MCP servers here (security-scanned). Manage running servers in the MCP Hub.',
};
/** Scan/trust → ApprovalModal risk. P7/D15 A7: delegates to the shared
* classifyInstallRisk so every install surface maps the same scan/trust signal
* to the same risk level (divergence #8). Intentionally retained as the
* canonical install-risk mapping, regression-locked by p7-a7-install-risk; it
* has NO production render-path caller (install is one-click, §1) — do not
* re-wire an install ApprovalModal off this chain without a design decision. */
export function installRiskFor(ext: Extension): ApprovalRequest['riskLevel'] {
return classifyInstallRisk({ scanStatus: ext.scanStatus, trust: ext.trust });
}
/** Uninstall confirm — destructive actions must not be one-click while the
* non-destructive install direction is (§1). */
export function buildRemoveRequest(ext: Extension): ApprovalRequest {
return {
action: `Remove "${ext.name}"?`,
scope: [
'Uninstalls the package and the skills it provides',
'Recorded in the install audit trail',
],
riskLevel: actionRisk('install-remove'),
};
}
/** Curated "Start here" shelf — a handful of well-known marks lifted above the
* All grid so a first visit has an obvious entry point. R10: lead with the
* memory-feeding connectors (Gmail / Drive / Notion / Slack) — the ones that
* make Waggle's memory richer — and demote 1Password. Honest by construction:
* matched against the LOADED list only (first 3 hits, band hidden under 2
* matches) — never fabricated entries. */
const START_HERE_IDS = ['gmail', 'gdrive', 'notion', 'slack', 'github', '1password', 'postgres', 'airtable'] as const;
export function startHerePicks(list: Extension[]): Extension[] {
const norm = (s: string) => s.toLowerCase().replace(/^(connector|mcp|pkg|pack):/, '').replace(/-mcp$/, '');
const picks: Extension[] = [];
for (const key of START_HERE_IDS) {
const hit = list.find(e => norm(e.id) === key || (e.name ?? '').trim().toLowerCase() === key);
if (hit && !picks.includes(hit)) picks.push(hit);
if (picks.length === 3) break;
}
return picks.length >= 2 ? picks : [];
}
/** Closest catalog entries for a described need when BOTH keyword filtering and
* the semantic match come up empty (Wave U Lane C §2) — a real, installable
* starting point instead of a dead-end. Ranked by loaded-token overlap on
* name/description (best first), then catalog order; never fabricated (drawn
* only from the loaded list). */
export function nearestCatalog(list: Extension[], query: string, n = 3): Extension[] {
const tokens = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
return [...list]
.map(e => {
const hay = `${e.name ?? ''} ${e.description ?? ''}`.toLowerCase();
return { e, score: tokens.reduce((s, t) => s + (hay.includes(t) ? 1 : 0), 0) };
})
.sort((a, b) => b.score - a.score)
.slice(0, n)
.map(x => x.e);
}
/** Structured install risk/provenance (regression-locked by p7-issue17). */
export function buildInstallRequest(ext: Extension): ApprovalRequest {
return {
action: `Install "${ext.name}" from the marketplace?`,
scope: [
`Type: ${ext.type}`,
`Source: ${ext.source}`,
ext.scanStatus
? `Security scan: ${ext.scanStatus === 'not_scanned' ? 'not scanned' : ext.scanStatus}`
: undefined,
'The install is recorded in the audit trail and can be removed afterwards',
].filter((s): s is string => s !== undefined),
riskLevel: installRiskFor(ext),
trustSource: installTrustSource(ext),
};
}
const MarketplaceApp = () => {
const { connecting } = useService();
// The shared install store owns installed/installing state + the count (D1).
const { installedCount, hydrate, uninstall } = useInstallStore();
const [tab, setTab] = useState<Tab>('browse');
// Route-cache: land on the shelf the user left, not a reset to All.
const [facet, setFacet] = useState<Facet>(() => facetCache.read(FACET_SLOT) ?? 'all');
const [query, setQuery] = useState('');
// ~150ms-debounced mirror of `query` for the CLIENT grid filter + view mode,
// so the first keystroke doesn't flash the full list before it narrows (Wave
// T Lane B §1). `query` itself still drives the (separately 300ms-debounced)
// server load below and the input's own value.
const [filterQuery, setFilterQuery] = useState('');
// Route-cache: seed the grid from the last-loaded list for the restored shelf
// so a return paints instantly, ahead of the silent refresh below.
const [extensions, setExtensions] = useState<Extension[]>(
() => listCache.read(surfaceCacheKey([facet, ''])) ?? [],
);
const [loading, setLoading] = useState(false);
// Wave V Lane E §2: true from the keystroke until its debounced fetch settles
// (covers the pre-fetch 300ms gap that `loading` alone misses). Drives the
// dimmed-but-mounted results so the list doesn't collapse between keystrokes.
const [searchPending, setSearchPending] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<Extension | null>(null);
const [removing, setRemoving] = useState(false);
const [shelfNote, setShelfNote] = useState<string | null>(null);
// Lifecycle of the NL auto-match (reported by AgentSearchBox) — lets the
// no-match area suppress its dead-end while matching / on a hit and show the
// catalog fallback only when the semantic match ALSO finds nothing (Lane C).
const [autoMatch, setAutoMatch] = useState<AutoMatchState>('idle');
// Monotonic request token — only the LATEST loadFacet call may commit state.
const requestSeq = useRef(0);
const loadFacet = useCallback(async (f: Facet, q: string) => {
const seq = ++requestSeq.current;
setLoading(true);
try {
const jobs: Array<Promise<Extension[]>> = [];
const want = (t: ExtensionType) => f === 'all' || f === t;
if (want('skill')) {
jobs.push((async () => {
const out: Extension[] = [];
const [pkgs, packs] = await Promise.allSettled([
adapter.getMarketplace({ type: 'skill', ...(q ? { query: q } : {}), limit: 30 }),
adapter.getMarketplacePacks(),
]);
if (pkgs.status === 'rejected' && packs.status === 'rejected') throw pkgs.reason;
if (pkgs.status === 'fulfilled') {
out.push(...((pkgs.value.packages ?? []) as MarketplacePackageRow[]).map(fromMarketplacePackage));
}
if (packs.status === 'fulfilled') {
out.push(...dedupePacks(packs.value).map(fromSkillPack));
}
return out;
})());
}
if (want('connector')) {
jobs.push(adapter.getConnectors().then(cs => cs.map(fromConnector)));
}
if (want('mcp')) {
// Local MCP Hub catalog (enableable in-place via the store, D3)…
jobs.push(adapter.getMcps().then(rows => (rows as McpCatalogRow[]).map(fromMcpCatalogRow)));
// …plus registry packages with waggle_install_type='mcp', which install
// through the real package route.
jobs.push(
adapter.getMarketplace({ type: 'mcp', ...(q ? { query: q } : {}), limit: 30 })
.then(r => ((r.packages ?? []) as MarketplacePackageRow[]).map(fromMarketplacePackage)),
);
}
const settled = await Promise.allSettled(jobs);
if (seq !== requestSeq.current) return; // stale — a newer request owns the state
const merged = settled.flatMap(s => (s.status === 'fulfilled' ? s.value : []));
// Collapse the 3 catalog sources (connector / catalog-mcp / package) into
// ONE entry per integration before sorting, so the grid shows one row +
// one action instead of the same integration up to 3×.
const sorted = sortExtensions(dedupeExtensions(merged));
const allRejected = settled.length > 0 && settled.every(s => s.status === 'rejected');
setExtensions(sorted);
setShelfNote(f !== 'all' ? SHELF_NOTES[f] ?? null : null);
setLoadError(allRejected
? 'Could not load extensions — the server may be unreachable.'
: null);
// Route-cache: remember the shelf + list so a return within the session
// repaints instantly — but NEVER cache the all-backends-down state (an
// empty error result must re-fetch, not seed a healthy-looking empty grid).
if (!allRejected) {
listCache.write(surfaceCacheKey([f, q]), sorted);
facetCache.write(FACET_SLOT, f);
}
} catch (err) {
if (seq === requestSeq.current) {
setExtensions([]);
setLoadError(err instanceof Error ? err.message : 'Failed to load extensions');
}
} finally {
if (seq === requestSeq.current) { setLoading(false); setSearchPending(false); }
}
}, []);
// Fetch once the connect attempt has settled AND on facet change. Re-hydrate
// the store too (D4) so installs made in the Hubs reconcile into the grid.
useEffect(() => {
if (connecting) return;
void loadFacet(facet, query);
void hydrate();
// eslint-disable-next-line react-hooks/exhaustive-deps -- query is read live; query EDITS go through the debounced effect below
}, [connecting, facet, loadFacet, hydrate]);
// Debounced search — server query for marketplace facets, client filter below.
const lastQueryRef = useRef(query);
useEffect(() => {
if (connecting) return;
if (lastQueryRef.current === query) return;
lastQueryRef.current = query;
setSearchPending(true);
const t = setTimeout(() => void loadFacet(facet, query), 300);
return () => clearTimeout(t);
}, [connecting, facet, query, loadFacet]);
// Debounce the CLIENT grid filter (Wave T Lane B §1) — the grouped view + the
// narrowed list hold steady until typing settles, so the first keystroke no
// longer flashes a near-full flat list before it filters down.
useEffect(() => {
const t = setTimeout(() => setFilterQuery(query), 150);
return () => clearTimeout(t);
}, [query]);
/** Remove confirmed → uninstall through the store so the count bar + every
* other view reflect it. The store toasts + reconciles on failure. */
const handleUninstall = async (ext: Extension) => {
setRemoving(true);
try {
await uninstall(ext);
} finally {
setRemoving(false);
}
};
const handleOpenIn = (appId: string) => {
window.dispatchEvent(new CustomEvent('waggle:open-app', { detail: { appId } }));
};
const visible = filterExtensions(extensions, filterQuery);
// A search/reload is settling — a server fetch is in flight OR we're still in
// the keystroke→fetch debounce gap. The results container stays mounted and
// dims (aria-busy) rather than collapsing between keystrokes (Wave V Lane E §2).
const busy = loading || searchPending;
// Route-cache: once the shelf has resolved this session, the cold spinner
// never returns — a silent refresh over a genuinely-empty catalog shows the
// empty state, not a fresh "Loading extensions…" (the shelf-cache lesson).
const surfaceResolved = facetCache.hasResolved(FACET_SLOT);
// NL bridge (Wave U Lane C §1): a query that reads like a described need
// (≥3 words) with no keyword match auto-runs the semantic engine instead of
// dead-ending. The engine + results live in AgentSearchBox above; here we only
// hand it the need and compose the fallback if it too comes up empty. Wave V
// Lane E §2: keyed on the settled query, NOT on `loading`, so a transient
// reload no longer tears down and remounts the "Matched to your request"
// section across adjacent debounce ticks — it holds until the query changes.
const isNlQuery = filterQuery.trim().split(/\s+/).filter(Boolean).length >= 3;
const nlNoMatch = isNlQuery && !loadError && visible.length === 0;
const autoMatchNeed = nlNoMatch ? filterQuery.trim() : null;
const nearest = nlNoMatch && autoMatch === 'empty' ? nearestCatalog(extensions, filterQuery) : [];
// Wave W Lane C §2: the semantic three-up returns AT MOST 3 hits — a 1-2-pick
// answer leaves the matched surface sparse. On a hit, append a quiet "More
// from the catalog" rail (nearestCatalog, ≤3) below the picks so the result
// never strands the user in dark space.
const catalogRail = nlNoMatch && autoMatch === 'matched' ? nearestCatalog(extensions, filterQuery) : [];
// Round-4 merchandising: the band renders on the default All browse only
// (no active query); banded entries are lifted OUT of the grid below so
// each integration keeps exactly one row + one action.
const startHere = facet === 'all' && !filterQuery ? startHerePicks(extensions) : [];
const gridVisible = startHere.length > 0
? visible.filter(e => !startHere.some(f => f.id === e.id))
: visible;
// Round-5 merchandising: the default All browse groups by type with section
// headers instead of one alphabetical mixed-type dump. A live query (or a
// typed facet) keeps the flat relevance list.
const groupedSections: Array<{ label: string; items: Extension[] }> =
facet === 'all' && !filterQuery
? (['skill', 'connector', 'mcp'] as const)
.map(t => ({ label: FACET_LABELS[t], items: gridVisible.filter(e => e.type === t) }))
.concat([{ label: 'More', items: gridVisible.filter(e => !['skill', 'connector', 'mcp'].includes(e.type)) }])
.filter(s => s.items.length > 0)
: [];
return (
<div className="flex flex-col h-full">
{/* Header — inner content shares the centered browse column below so the
facet rail and count line up with the rows (round-6: full-bleed rows
put actions a long eye-travel from titles). */}
<div className="px-4 py-3 border-b border-border/30">
<div className="mx-auto w-full max-w-[860px]">
<div className="flex items-center gap-3 mb-3">
<Store className="w-5 h-5" style={{ color: 'var(--honey-500)' }} />
<h2 className="text-sm font-display font-semibold text-foreground">Marketplace</h2>
{/* D1: honest global count of installed/connected/enabled capabilities. */}
<span data-testid="install-count" className="text-[11px] text-muted-foreground ml-auto">
{installedCount} installed
</span>
</div>
<div className="flex gap-1 mb-3" role="tablist" aria-label="Marketplace sections">
{(['browse', 'audit'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
role="tab"
aria-selected={tab === t}
className={`px-3 py-1 text-xs font-display rounded-lg transition-colors ${
tab === t ? 'bg-primary/20 text-honey' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'browse' ? 'Browse' : 'Audit'}
</button>
))}
</div>
{tab === 'browse' && (
<>
{/* Four-shelf rail (D2) */}
<div className="flex flex-wrap gap-1.5 mb-3" data-testid="extension-facets">
{SHELVES.map(f => (
<button
key={f}
onClick={() => setFacet(f)}
aria-pressed={facet === f}
className={`rounded-full px-2.5 py-0.5 text-[11px] font-medium transition-[background-color,color,box-shadow] ${
facet === f
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/30'
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}`}
>
{FACET_LABELS[f]}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Body — constrained to a centered readable column instead of a
full-bleed list. */}
<div className="flex-1 overflow-auto p-3" role="tabpanel">
<div className="mx-auto w-full max-w-[860px] space-y-2">
{tab === 'audit' ? (
<InstallAuditPanel showFilter limit={30} />
) : (
<>
{/* ONE smart input (H-round merge): keystrokes filter the grid
below live; Enter asks the agent-search engine for a capability
three-up. Replaces the former separate header search field. */}
<AgentSearchBox onQueryChange={setQuery} autoRunNeed={autoMatchNeed} onAutoStateChange={setAutoMatch} />
<div className="border-t border-border/20 my-1" />
<InstallFromUrlRow onHeld={setShelfNote} />
{startHere.length > 0 && (
<div data-testid="start-here-band" className="space-y-2">
<p className="text-[11px] font-display font-semibold text-[var(--honey-text)] uppercase tracking-wider">
Start here
</p>
{startHere.map(ext => (
<ExtensionCard
key={ext.id}
ext={ext}
onRemove={setRemoveTarget}
onOpenIn={handleOpenIn}
/>
))}
<div className="border-t border-border/20" aria-hidden />
</div>
)}
{shelfNote && (
<p data-testid="federated-note" className="text-[11px] text-muted-foreground bg-muted/40 border border-border/30 rounded-lg px-2.5 py-1.5">
{shelfNote}
</p>
)}
{/* Cold load only — once ANY extensions are loaded (or the shelf has
resolved this session), a reload dims the existing list (below)
instead of collapsing to this spinner. */}
{loading && extensions.length === 0 && !surfaceResolved && (
<div className="text-center py-8">
<Loader2 className="w-6 h-6 text-muted-foreground/40 mx-auto mb-2 animate-spin" />
<p className="text-xs text-muted-foreground">Loading extensions...</p>
</div>
)}
{!loading && loadError && (
<div role="alert" className="text-center py-8">
<p className="text-xs text-destructive mb-2">{loadError}</p>
<button
onClick={() => void loadFacet(facet, query)}
className="text-xs text-honey hover:underline"
>
Retry
</button>
</div>
)}
{!busy && !loadError && visible.length === 0 && (
isNlQuery ? (
// Described need, no keyword hit: the semantic match runs itself
// (AgentSearchBox above shows the BeeLoader + ranked results under
// "Matched to your request"). We compose a fallback ONLY when that
// match also finds nothing — closest catalog entries + a real
// escape, never a gray "no match by name" dead-end (Lane C §2).
autoMatch === 'empty' ? (
<div data-testid="nl-no-match-fallback" className="space-y-2 py-1">
{nearest.length > 0 && (
<>
<p className="px-1 text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider">
Closest in the catalog
</p>
{nearest.map(ext => (
<ExtensionCard key={ext.id} ext={ext} onRemove={setRemoveTarget} onOpenIn={handleOpenIn} />
))}
</>
)}
<button
type="button"
onClick={() => handleOpenIn('home')}
data-testid="nl-ask-agent"
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--honey-line)] bg-[var(--honey-wash)] px-3 py-2 text-xs font-medium text-[var(--honey-text)] transition-colors hover:bg-primary/15"
>
<Sparkles className="h-3.5 w-3.5" />
Ask your agent to do this instead
</button>
</div>
) : null
) : (
<div className="text-center py-8">
<Package className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-xs text-muted-foreground">
{filterQuery ? `No results for "${filterQuery}"` : 'No extensions available for this facet'}
</p>
</div>
)
)}
{/* Wave W Lane C §1: an NL query keyword-filters EVERYTHING out, so
the busy-dim on live results has nothing to hold. While the
semantic match settles (the "Matching skills to this job…" status
shows above), stand up 3 purpose-built result-row skeletons — icon
square + two text lines + chip stubs — instead of dimming the
now-wrong pre-query browse rows. Motion-safe (animate-none under
reduced motion). */}
{nlNoMatch && (autoMatch === 'idle' || autoMatch === 'searching') && (
<div data-testid="nl-matching-skeletons" aria-hidden className="space-y-2">
{[0, 1, 2].map(i => (
<div key={i} className="flex items-start gap-3 rounded-xl border border-border/30 bg-card px-3 py-2.5">
<Skeleton className="h-10 w-10 shrink-0 rounded-lg motion-reduce:animate-none" />
<div className="min-w-0 flex-1 space-y-2 pt-0.5">
<Skeleton className="h-3 w-2/5 motion-reduce:animate-none" />
<Skeleton className="h-3 w-4/5 motion-reduce:animate-none" />
<div className="flex gap-2 pt-0.5">
<Skeleton className="h-4 w-14 rounded-full motion-reduce:animate-none" />
<Skeleton className="h-4 w-12 rounded-full motion-reduce:animate-none" />
</div>
</div>
</div>
))}
</div>
)}
{/* Wave W Lane C §2: a matched three-up can be as few as 1-2 picks.
Append a quiet "More from the catalog" rail below it so the answer
never strands the user in dark space (reuses nearestCatalog, ≤3). */}
{catalogRail.length > 0 && (
<div data-testid="nl-more-catalog" className="space-y-2 pt-1">
<p className="px-1 text-[11px] font-display font-semibold uppercase tracking-wider text-muted-foreground">
More from the catalog
</p>
{catalogRail.map(ext => (
<ExtensionCard key={ext.id} ext={ext} onRemove={setRemoveTarget} onOpenIn={handleOpenIn} />
))}
</div>
)}
{/* Wave V Lane E §2: the matched-results container stays mounted and
only dims (aria-busy) while a search settles — it doesn't collapse
and rebuild between keystrokes. */}
<div
data-testid="marketplace-results"
aria-busy={busy || undefined}
className={`space-y-2 transition-opacity duration-mo-fast motion-reduce:transition-none ${busy ? 'opacity-60' : ''}`}
>
{groupedSections.length > 0
? groupedSections.map(section => (
<div key={section.label} data-testid={`marketplace-section-${section.label.toLowerCase()}`} className="space-y-2">
<p className="pt-2 text-[11px] font-display font-semibold text-muted-foreground uppercase tracking-wider">
{section.label} <span className="text-[var(--text-dim)] normal-case tracking-normal">· {section.items.length}</span>
</p>
{section.items.map(ext => (
<ExtensionCard key={ext.id} ext={ext} onRemove={setRemoveTarget} onOpenIn={handleOpenIn} />
))}
</div>
))
: gridVisible.map(ext => (
<ExtensionCard
key={ext.id}
ext={ext}
onRemove={setRemoveTarget}
onOpenIn={handleOpenIn}
/>
))}
</div>
</>
)}
</div>
</div>
{/* Remove confirm — destructive direction keeps its consequence dialog
even though install is one-click. */}
<ApprovalModal
request={removeTarget ? buildRemoveRequest(removeTarget) : null}
approveLabel="Remove"
busy={removing}
onApprove={() => {
const target = removeTarget;
setRemoveTarget(null);
if (target) void handleUninstall(target);
}}
onCancel={() => setRemoveTarget(null)}
/>
</div>
);
};
export default MarketplaceApp;

View File

@@ -0,0 +1,293 @@
import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Brain, Clock, Network, Download, Activity, BookOpen, Sparkles, User, Briefcase, ShieldCheck, SlidersHorizontal, ChevronDown } from 'lucide-react';
import type { KGNode, KGEdge } from '@/lib/types';
import { HintTooltip } from '@/components/ui/hint-tooltip';
import { SPRING } from '@/lib/motion/tokens';
import { cn } from '@/lib/utils';
import MemoryTrust from './MemoryTrust';
import KnowledgeGraphViewer from './memory/KnowledgeGraphViewer';
import HarvestTab from './memory/HarvestTab';
import WeaverPanel from './memory/WeaverPanel';
import WikiTab from './memory/WikiTab';
import EvolutionTab from './memory/EvolutionTab';
import MemoryCenterTab from './memory/MemoryCenterTab';
import TimelineTab, { type TimelineTabProps } from './memory/TimelineTab';
import ImportReminderBanner from './memory/ImportReminderBanner';
import { useOnboarding } from '@/hooks/useOnboarding';
/**
* Memory Center (P3/D2) — the standalone memory surface (ArtifactCenterApp
* shape: thin route wrapper + self-contained app component).
*
* Top-level structure is the ratified TWO-MIND SPLIT: "About you" reads the
* Personal Mind; "About this work" reads the active workspace's Mind. The
* per-mind list is MemoryCenterTab (reused, parameterized — D2). The legacy
* MemoryApp views (Timeline / Graph / Harvest / Weaver / Wiki / Evolution)
* survive as secondary tabs after a divider — capability preserved, entry
* restructured. Mind pills render only on the Memories tab: the legacy tabs
* keep their own scoping (Graph has a scope selector; Harvest writes to the
* personal mind by design).
*
* Controlled component (the ratified WorkspaceDesktopApp two-seam pattern):
* the route owns mind + view via URL (`/memory/:mindScope?` + `?tab=`); this
* component only renders and reports intent via onMindChange/onViewChange.
*/
export type MindScope = 'personal' | 'workspace';
export type MemoryView = 'trust' | 'memories' | 'timeline' | 'graph' | 'harvest' | 'weaver' | 'wiki' | 'evolution';
// PR3.5: 'trust' (screen 19) is the new PRIMARY front door — first + default;
// the legacy views demote to secondary (after the divider).
export const MEMORY_VIEWS: readonly MemoryView[] = [
'trust', 'memories', 'timeline', 'graph', 'harvest', 'weaver', 'wiki', 'evolution',
];
interface MemoryTabDef {
id: MemoryView;
label: string;
icon: React.ComponentType<{ className?: string }>;
tooltip: string;
}
// UX gold-standard H1: 8 equal-weight tabs regrouped into 4 primary + an
// "Advanced" flyout (display-level IA only — every view id, route and
// ?tab= deep-link is unchanged; MEMORY_VIEWS still validates all 8).
const PRIMARY_TABS: MemoryTabDef[] = [
{ id: 'trust', label: 'Trust', icon: ShieldCheck, tooltip: 'Memory Trust — confidence & freshness, forget / correct / confirm, and "why did you do that?"' },
{ id: 'memories', label: 'Memories', icon: Brain, tooltip: 'Memory Center — inspect, edit, review, merge' },
{ id: 'timeline', label: 'Timeline', icon: Clock, tooltip: 'Chronological frame list' },
{ id: 'graph', label: 'Graph', icon: Network, tooltip: 'Knowledge Graph — entities and relations' },
];
const ADVANCED_TABS: MemoryTabDef[] = [
{ id: 'harvest', label: 'Imports', icon: Download, tooltip: 'Import conversations from other AIs' },
{ id: 'weaver', label: 'Maintenance', icon: Activity, tooltip: 'Memory distillation and consolidation' },
{ id: 'wiki', label: 'Wiki', icon: BookOpen, tooltip: 'Compiled knowledge pages' },
{ id: 'evolution', label: 'Improvements', icon: Sparkles, tooltip: 'Self-evolving prompts and agents' },
];
export interface MemoryCenterAppProps {
mind: MindScope;
onMindChange: (mind: MindScope) => void;
view: MemoryView;
onViewChange: (view: MemoryView) => void;
/** Active workspace — powers the "About this work" mind. Absent → pill disabled. */
workspaceId?: string;
workspaceName?: string;
/** Legacy Timeline tab pass-through (useMemory hook surface, via the route). */
timeline: TimelineTabProps;
/** Legacy Graph tab pass-through (useKnowledgeGraph hook surface). */
knowledgeGraph?: { nodes: KGNode[]; edges: KGEdge[] };
onRefreshKG?: () => void;
kgScope?: 'current' | 'personal' | 'all';
onKGScopeChange?: (scope: 'current' | 'personal' | 'all') => void;
kgLoading?: boolean;
kgError?: string | null;
onContextRail?: (target: { type: 'frame' | 'entity'; id: string; label: string }) => void;
}
const MemoryCenterApp = ({
mind, onMindChange, view, onViewChange, workspaceId, workspaceName,
timeline, knowledgeGraph, onRefreshKG, kgScope, onKGScopeChange,
kgLoading = false, kgError = null, onContextRail,
}: MemoryCenterAppProps) => {
// Reminder-banner eligibility (carried over from the retired MemoryApp shell).
const { state: onboardingState } = useOnboarding();
// Advanced flyout (display-level IA regroup): open state + active child.
const [advancedOpen, setAdvancedOpen] = useState(false);
const activeAdvanced = ADVANCED_TABS.find((t) => t.id === view);
// Lane HM (Pillar 1.1): the memory tab panel morphs between views (the
// Trust↔Memories pair especially) rather than hard-cutting — a keyed
// opacity+settle on the panel container. Reduced motion → instant swap.
const reduceMotion = !!useReducedMotion();
return (
<div className="flex flex-col h-full" data-testid="memory-center-app">
<ImportReminderBanner
onboardingCompleted={onboardingState.completed}
totalFrameCount={timeline.stats.total}
onOpenHarvest={() => onViewChange('harvest')}
/>
{/* Tab bar + mind scope — Wave T Lane D (item 4): the memory surface stacked
THREE control tiers (tabs · mind pills · the Trust Manage/Why switch).
Fold the weakest full-width tier — the mind pills — up onto the tab-bar
row as a right-aligned scope control, so the surface reads as two tiers,
not three. Every destination is preserved; only the chrome collapses. */}
<div className="flex flex-wrap items-center gap-y-1 border-b border-border/50 bg-background/60">
<div role="tablist" aria-label="Memory views" className="flex min-w-0 flex-wrap items-center">
{PRIMARY_TABS.map((tab, i) => {
const Icon = tab.icon;
const active = view === tab.id;
return (
<div key={tab.id} className="flex items-center">
{i === 1 && <div className="w-px h-4 bg-border/60 mx-1" aria-hidden="true" />}
<HintTooltip content={tab.tooltip}>
<button
role="tab"
onClick={() => onViewChange(tab.id)}
aria-selected={active}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-display border-b-2 transition-colors',
active
? 'border-primary text-honey bg-primary/5'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
)}
>
<Icon className="w-3.5 h-3.5" />
<span>{tab.label}</span>
</button>
</HintTooltip>
</div>
);
})}
{/* Advanced — a flyout tab holding the maintenance/import views. The
trigger names the active child (e.g. "Advanced: Imports") so a
deep-linked ?tab=harvest never looks like no tab is selected. */}
<div className="flex items-center">
<div className="w-px h-4 bg-border/60 mx-1" aria-hidden="true" />
<div className="relative">
<HintTooltip content="Imports, maintenance, wiki, and improvements">
<button
role="tab"
onClick={() => setAdvancedOpen(o => !o)}
aria-selected={!!activeAdvanced}
aria-haspopup="menu"
aria-expanded={advancedOpen}
data-testid="memory-tab-advanced"
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-display border-b-2 transition-colors',
activeAdvanced
? 'border-primary text-honey bg-primary/5'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
)}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
<span>{activeAdvanced ? `Advanced: ${activeAdvanced.label}` : 'Advanced'}</span>
<ChevronDown className={cn('w-3 h-3 transition-transform', advancedOpen && 'rotate-180')} />
</button>
</HintTooltip>
{advancedOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAdvancedOpen(false)} />
<div
role="menu"
aria-label="Advanced memory views"
data-testid="memory-tab-advanced-menu"
className="absolute left-0 top-full mt-1 z-50 w-56 bg-card border border-border rounded-xl shadow-xl overflow-hidden"
>
{ADVANCED_TABS.map((tab) => {
const Icon = tab.icon;
const active = view === tab.id;
return (
<button
key={tab.id}
role="menuitem"
onClick={() => { onViewChange(tab.id); setAdvancedOpen(false); }}
className={cn(
'w-full text-left px-3 py-2 text-xs flex items-center gap-2 transition-colors',
active ? 'text-honey bg-primary/5' : 'text-foreground hover:bg-muted/50',
)}
>
<Icon className="w-3.5 h-3.5 shrink-0" />
<div className="min-w-0">
<div className="font-display">{tab.label}</div>
<div className="text-[11px] text-muted-foreground truncate">{tab.tooltip}</div>
</div>
</button>
);
})}
</div>
</>
)}
</div>
</div>
</div>
{/* Mind pills — folded onto the tab row (Wave T Lane D item 4). Only on
the Trust/Memories views (D2 two-mind split). The workspace pill is
never pressed AND disabled at once: without a workspace there is no
workspace mind to be "on", even if the URL says /memory/workspace (the
list shows the no-workspace hint instead). */}
{(view === 'memories' || view === 'trust') && (
<div role="group" aria-label="Which mind to show" className="ml-auto flex items-center gap-1.5 border-l border-border/40 px-2.5 py-1.5">
<button
onClick={() => onMindChange('personal')}
aria-pressed={mind === 'personal'}
data-testid="memory-mind-personal"
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-display transition-colors border',
mind === 'personal'
? 'border-primary/40 bg-primary/15 text-honey'
: 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground',
)}
>
<User className="w-3 h-3" /> About you
</button>
<HintTooltip content={workspaceId ? `What this workspace has learned` : 'Open a workspace first — Home is the workspace selector'}>
<button
onClick={() => workspaceId && onMindChange('workspace')}
aria-pressed={mind === 'workspace' && !!workspaceId}
aria-disabled={!workspaceId}
data-testid="memory-mind-workspace"
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-display transition-colors border',
!workspaceId && 'opacity-50 cursor-not-allowed',
mind === 'workspace' && workspaceId
? 'border-primary/40 bg-primary/15 text-honey'
: 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground',
)}
>
<Briefcase className="w-3 h-3" /> About this work{workspaceName ? ` · ${workspaceName}` : ''}
</button>
</HintTooltip>
</div>
)}
</div>
<div className="flex-1 overflow-auto">
{/* Lane HM: the panel body morphs on view change (Trust↔Memories the
hero pair) — keyed opacity + a small settle, not a hard cut. The
container's scroll stays on the parent; reduced motion swaps
instantly (initial disabled, zero-duration transition). */}
<motion.div
key={view}
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : SPRING.standard}
data-testid="memory-view-panel"
>
{view === 'trust' ? (
<MemoryTrust mind={mind} workspaceId={workspaceId} />
) : view === 'memories' ? (
<MemoryCenterTab mind={mind} workspaceId={workspaceId} />
) : view === 'timeline' ? (
<TimelineTab {...timeline} onContextRail={onContextRail} />
) : view === 'graph' ? (
<KnowledgeGraphViewer nodes={knowledgeGraph?.nodes || []} edges={knowledgeGraph?.edges || []}
scope={kgScope} onScopeChange={onKGScopeChange}
loading={kgLoading} error={kgError} onRetry={onRefreshKG}
onNodeClick={(nodeId) => {
const node = knowledgeGraph?.nodes.find(n => n.id === nodeId);
if (node && onContextRail) onContextRail({ type: 'entity', id: nodeId, label: node.label ?? nodeId });
}} />
) : view === 'harvest' ? (
<HarvestTab />
) : view === 'weaver' ? (
<WeaverPanel />
) : view === 'wiki' ? (
<WikiTab />
) : (
<EvolutionTab />
)}
</motion.div>
</div>
</div>
);
};
export default MemoryCenterApp;

View File

@@ -0,0 +1,242 @@
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { ShieldCheck, Info } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import MemoryTrustManage from './memory/MemoryTrustManage';
import MemoryTrustWhy from './memory/MemoryTrustWhy';
import { cn } from '@/lib/utils';
/**
* Memory Trust (screen 19, DESIGN_POV #1) — the warm-Hive PR3.5 front door for
* the Memory surface. Two views behind a segmented control:
* - "Manage memory" (default): confidence/freshness + forget/correct/confirm.
* - "Why did you do that?": the goal→recall→checks→action trace.
*
* PR3.5 PHASING — Phase A shipped the shell; **Phase B+C** built the native
* Manage body (`MemoryTrustManage`: stat bar + filter chips + confidence-ring
* rows with the 3-segment ⬡ provenance and forget/correct/confirm) in a single
* editorial scroll. Phase D builds the real "Why?" trace from
* `adapter.getMemoryTrace`. Until then the Why view shows an honest empty state
* — never a synthesized reason.
*
* Theme toggle (the mock's ☾/☀) is intentionally omitted — PR1's global
* ThemeProvider already owns theme; a screen-local toggle would be redundant.
*/
type TrustView = 'manage' | 'why';
interface MemoryTrustProps {
/** Which mind this surface reads/writes (D2 two-mind split). */
mind: 'personal' | 'workspace';
/** Required when mind='workspace' — the workspace whose mind to show. */
workspaceId?: string;
}
const SEGMENTS: { id: TrustView; label: string }[] = [
{ id: 'manage', label: 'Manage memory' },
{ id: 'why', label: 'Why did you do that?' },
];
/** Editorial eyebrow + honey-accented H1 + body, per the §2 design contract.
* UX gold-standard H1 (deterministic): the full manifesto renders only while
* the store is effectively empty; once real memories exist the compact form
* (eyebrow + smaller H1, paragraph hidden) lets the stat block lead.
* `controls` renders the Manage/Why segmented switch inline on the eyebrow
* row — one register instead of a separate labelled strip above the hero. */
function ManageHero({ compact, controls }: { compact: boolean; controls?: ReactNode }) {
return (
<header className="mb-1" data-testid="memory-trust-hero" data-compact={compact ? 'true' : 'false'}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
{/* --honey-text (theme-aware AA token) — raw --honey misses AA at caption size in light. */}
<p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-[var(--honey-text)]">
<span className="h-px w-5 bg-[var(--honey)]" aria-hidden="true" />
Trust · inspect · correct · forget
</p>
{controls}
</div>
<h1 className={cn(
'font-[650] leading-tight tracking-[-0.02em] text-[var(--text)]',
compact ? 'text-[20px]' : 'text-[28px]',
)}>
Memory you can <span className="text-[var(--honey-text)]">correct, age, and forget.</span>
</h1>
{!compact && (
<p className="mt-3 max-w-[64ch] text-[15px] leading-[1.55] text-[var(--text-muted)]">
A memory that only grows is a liability. Waggle shows you{' '}
<b className="font-semibold text-[var(--text-2)]">how sure it is</b>,{' '}
<b className="font-semibold text-[var(--text-2)]">how fresh it is</b>, and{' '}
<b className="font-semibold text-[var(--text-2)]">where it came from</b> and lets you fix or
forget anything. You&rsquo;re always in control of what the hive believes.
</p>
)}
</header>
);
}
function WhyHero({ controls }: { controls?: ReactNode }) {
return (
<header className="mb-1">
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-[var(--honey-text)]">
<span className="h-px w-5 bg-[var(--honey)]" aria-hidden="true" />
Provenance · accountability
</p>
{controls}
</div>
<h1 className="text-[28px] font-[650] leading-tight tracking-[-0.02em] text-[var(--text)]">
Ask the agent <span className="text-[var(--honey-text)]">&ldquo;why did you do that?&rdquo;</span>
</h1>
<p className="mt-3 max-w-[64ch] text-[15px] leading-[1.55] text-[var(--text-muted)]">
Any action an agent takes can be traced back to the exact memories and sources behind it
so a wrong move is <b className="font-semibold text-[var(--text-2)]">diagnosable, not
mysterious</b>. If a bad memory caused it, fix the memory right from the trace.
</p>
</header>
);
}
/** §7 trust-principle footnote — rendered as a persistent footer in both views. */
function TrustPrincipleFooter({ view }: { view: TrustView }) {
const Icon = view === 'manage' ? ShieldCheck : Info;
return (
<div className="shrink-0 border-t border-[var(--line-soft)] bg-[var(--bg-2)] px-6 py-3.5">
<div className="mx-auto flex max-w-[920px] items-start gap-3">
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-[var(--healthy)]" strokeWidth={1.8} />
<p className="text-[13px] leading-[1.6] text-[var(--text-muted)]">
{view === 'manage' ? (
<>
<b className="text-[var(--text)]">Nothing is remembered behind your back.</b> Every memory
is inspectable, editable, and forgettable and forgetting is real: it&rsquo;s removed from
recall and from anything Waggle says next. Confidence and freshness are shown so the agent
(and you) can discount what&rsquo;s old or shaky instead of acting on it blindly.
</>
) : (
<>
<b className="text-[var(--text)]">Every agent action keeps its trace.</b> The chain from
goal recalled memories checks action is stored with the result, so &ldquo;why did you
do that?&rdquo; always has an answer and the fix (correct or forget the offending memory)
is one click from the explanation.
</>
)}
</p>
</div>
</div>
);
}
export default function MemoryTrust({ mind, workspaceId }: MemoryTrustProps) {
const [view, setView] = useState<TrustView>('manage');
// Hero demote — DETERMINISTIC: full manifesto only while the store is
// effectively empty (total unknown or 0); compact once real memories exist.
// (Was a localStorage first-visit flag, which rendered different content
// across fresh audit profiles and read as a dark/light parity bug.)
const [storeTotal, setStoreTotal] = useState<number | null>(null);
const heroCompact = (storeTotal ?? 0) > 0;
const [toast, setToast] = useState<string | null>(null);
// Cross-view accountability loop: Manage row → "Why?" sets the trace target +
// switches to Why; the Why view's "correct it" hands an id back to Manage's editor.
const [traceMemoryId, setTraceMemoryId] = useState<string | null>(null);
const [pendingOpenId, setPendingOpenId] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const wsParam = mind === 'workspace' ? workspaceId : undefined;
const showToast = useCallback((msg: string) => {
setToast(msg);
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2400);
}, []);
// Clear a pending toast timer on unmount (e.g. leaving the Trust tab) so it
// can't fire setState after unmount (review L).
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
const clearPendingOpen = useCallback(() => setPendingOpenId(null), []);
const handleTotal = useCallback((n: number) => setStoreTotal(n), []);
const goToTrace = useCallback((id: string) => { setTraceMemoryId(id); setView('why'); }, []);
const correctFromTrace = useCallback((id: string) => { setPendingOpenId(id); setView('manage'); }, []);
const forgetFromTrace = useCallback(async (id: string) => {
try {
await adapter.deleteMemoryById(id, wsParam, mind);
showToast(`Forgotten M-${id} — removed from recall`);
setTraceMemoryId(null);
setView('manage');
} catch {
showToast('Could not forget that memory');
}
}, [wsParam, mind, showToast]);
// Segmented Manage/Why switch — rendered INLINE on the hero eyebrow row (one
// register; the old labelled strip + helper sentence doubled what the hero and
// the trust footer already say). Plain toggle buttons (aria-pressed), NOT a
// role=tablist: it's nested inside the MemoryCenterApp tab bar and has no
// arrow-key tablist semantics — toggle buttons are natively keyboard-operable
// (review HIGH).
const segmented = (
<div role="group" aria-label="Memory Trust view" className="flex gap-0.5 rounded-[10px] border border-[var(--line-soft)] bg-[var(--surface-2)] p-[3px]">
{SEGMENTS.map((s) => {
const on = view === s.id;
return (
<button
key={s.id}
type="button"
aria-pressed={on}
onClick={() => setView(s.id)}
className={cn(
'rounded-[8px] px-3 py-1.5 text-[12.5px] font-medium transition-colors',
on ? 'bg-[var(--honey)] text-[#1a1407]' : 'text-[var(--text-muted)] hover:text-[var(--text)]',
)}
>
{s.label}
</button>
);
})}
</div>
);
return (
<div className="relative flex h-full flex-col">
{/* Stage — single editorial scroll per view (§ screen-19 layout) */}
<div className="min-h-0 flex-1 overflow-auto">
<div className="mx-auto w-full max-w-[920px] space-y-6 px-8 py-7">
{view === 'manage' ? (
<>
<ManageHero compact={heroCompact} controls={segmented} />
<MemoryTrustManage
mind={mind}
workspaceId={workspaceId}
onToast={showToast}
onWhy={goToTrace}
openMemoryId={pendingOpenId}
onOpenConsumed={clearPendingOpen}
onTotal={handleTotal}
/>
</>
) : (
<>
<WhyHero controls={segmented} />
<MemoryTrustWhy
mind={mind}
workspaceId={workspaceId}
memoryId={traceMemoryId}
onToast={showToast}
onCorrect={correctFromTrace}
onForget={forgetFromTrace}
/>
</>
)}
</div>
</div>
<TrustPrincipleFooter view={view} />
{/* Shared toast (§7) — green slide-up confirmation for forget/correct/confirm.
Sits above the persistent principle footer (review L). */}
{toast && (
<div className="pointer-events-none absolute bottom-20 left-1/2 z-50 -translate-x-1/2" role="status" aria-live="polite">
<div className="flex items-center gap-2 rounded-full border border-[var(--healthy)] bg-[var(--surface)] px-4 py-2 text-[13px] text-[var(--text)] shadow-lg">
<span className="h-2 w-2 rounded-full bg-[var(--healthy)]" aria-hidden="true" />
{toast}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import { Play, Pause, Square, Radio, Clock, Zap, RefreshCw, Users, Plus, Rocket, AlertCircle } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { DATE_LOCALE } from '@/lib/date-locale';
import type { FleetSession, Workspace } from '@/lib/types';
import { Button } from '@/components/ui/button';
interface MissionControlAppProps {
onSpawnOpen?: () => void;
}
/**
* AI-OS Phase 4 polish — compact inventory of installed AI tools at
* the top of Mission Control. One line: detected · installed ·
* hook-wired counts, with an "Open Launcher" button to jump to the
* dedicated dock app.
*/
interface ToolInventoryCounts {
detected: number;
installed: number;
hooked: number;
}
/**
* Per-tab load errors (error-as-empty rule, notes/error-as-empty.md): each
* data tab needs an ERROR render distinct from its EMPTY render so a fetch
* failure never masquerades as a genuinely empty fleet/team/activity feed.
*/
interface SectionErrors {
fleet: string | null;
team: string | null;
activity: string | null;
}
const NO_ERRORS: SectionErrors = { fleet: null, team: null, activity: null };
const MissionControlApp = ({ onSpawnOpen }: MissionControlAppProps) => {
const [sessions, setSessions] = useState<FleetSession[]>([]);
const [loading, setLoading] = useState(true);
const [teamMembers, setTeamMembers] = useState<{ id: string; name: string; status: string }[]>([]);
const [activity, setActivity] = useState<{ id: string; user: string; action: string; timestamp: string }[]>([]);
const [tab, setTab] = useState<'fleet' | 'team' | 'activity'>('fleet');
const [toolCounts, setToolCounts] = useState<ToolInventoryCounts | null>(null);
const [errors, setErrors] = useState<SectionErrors>(NO_ERRORS);
const reason = (r: PromiseRejectedResult, fallback: string): string =>
r.reason instanceof Error ? r.reason.message : fallback;
const refresh = async () => {
// Reset loading + clear stale errors so Retry gives visible feedback that
// the re-fetch fired (otherwise the error panel lingers with no spinner).
setLoading(true);
setErrors(NO_ERRORS);
const [fleet, members, act, tools] = await Promise.allSettled([
adapter.getFleet(),
adapter.getTeamMembers(),
adapter.getTeamActivity(),
adapter.detectTools(),
]);
if (fleet.status === 'fulfilled') setSessions(fleet.value);
if (members.status === 'fulfilled') setTeamMembers(members.value);
if (act.status === 'fulfilled') setActivity(act.value);
if (tools.status === 'fulfilled' && tools.value) {
const ts = tools.value.tools;
setToolCounts({
detected: ts.length,
installed: ts.filter((t) => t.installed).length,
hooked: ts.filter((t) => t.hooksInstalled).length,
});
}
// Thread each tab's load error so the ERROR render stays distinct from the
// EMPTY render (error-as-empty rule); clear on success.
setErrors({
fleet: fleet.status === 'rejected' ? reason(fleet, 'Failed to load fleet sessions') : null,
team: members.status === 'rejected' ? reason(members, 'Failed to load team members') : null,
activity: act.status === 'rejected' ? reason(act, 'Failed to load activity') : null,
});
setLoading(false);
};
const openLauncher = () => {
// Dispatch the standard OS event the dock listens for.
window.dispatchEvent(
new CustomEvent('waggle:open-app', { detail: { appId: 'launcher' } }),
);
};
useEffect(() => {
refresh();
const interval = setInterval(refresh, 3000);
return () => clearInterval(interval);
}, []);
const handleAction = async (workspaceId: string, action: 'pause' | 'resume' | 'stop') => {
await adapter.fleetAction(workspaceId, action);
refresh();
};
const statusColors: Record<string, string> = {
active: 'text-emerald-400',
paused: 'text-amber-400',
idle: 'text-muted-foreground',
};
const iconButtonFocus = 'focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background';
return (
<div className="h-full overflow-auto p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-display font-semibold text-foreground flex items-center gap-2">
<Radio className="w-5 h-5 text-honey" /> Mission Control
</h2>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="default"
className="gap-1.5 text-xs h-8"
onClick={onSpawnOpen}
>
<Plus className="w-3.5 h-3.5" /> Spawn Agent
</Button>
<button type="button" onClick={refresh} aria-label="Refresh Mission Control" className={`p-1.5 rounded-lg text-muted-foreground hover:text-foreground transition-colors ${iconButtonFocus}`}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
</button>
</div>
</div>
{/* AI Tools inventory (Phase 4 polish) */}
{toolCounts && (
<div className="flex items-center gap-3 mb-4 p-2.5 rounded-lg bg-secondary/30 border border-border/40 text-xs">
<Rocket className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />
<span className="text-muted-foreground">
<span className="text-foreground font-medium">{toolCounts.detected}</span> tools known ·{' '}
<span className="text-emerald-400 font-medium">{toolCounts.installed}</span> installed ·{' '}
<span className="text-amber-300 font-medium">{toolCounts.hooked}</span> hook-wired
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={openLauncher}
className="h-7 text-[11px] gap-1"
>
Open Launcher
</Button>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 mb-4 p-0.5 rounded-lg bg-muted/50 w-fit">
{(['fleet', 'team', 'activity'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 text-xs rounded-md font-display transition-colors capitalize ${
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t}
</button>
))}
</div>
{tab === 'fleet' && (
<>
{errors.fleet && sessions.length === 0 ? (
<div role="alert" className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="w-10 h-10 text-destructive/50 mb-3" />
<p className="text-sm text-foreground">Couldn't load fleet sessions</p>
<p className="text-xs text-muted-foreground/60 max-w-xs">This is a load error, not an empty fleet. {errors.fleet}</p>
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs" onClick={refresh}>
<RefreshCw className="w-3.5 h-3.5" /> Retry
</Button>
</div>
) : sessions.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Radio className="w-10 h-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">No active fleet sessions</p>
<Button
variant="outline"
size="sm"
className="mt-3 gap-1.5 text-xs"
onClick={onSpawnOpen}
>
<Rocket className="w-3.5 h-3.5" /> Spawn your first agent
</Button>
</div>
)}
<div className="space-y-2">
{sessions.map(s => (
<div key={s.workspaceId} className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${s.status === 'active' ? 'bg-emerald-400 animate-pulse' : s.status === 'paused' ? 'bg-amber-400' : 'bg-muted-foreground'}`} />
<span className="text-sm font-display font-medium text-foreground">{s.workspaceName}</span>
<span className={`text-[11px] capitalize ${statusColors[s.status]}`}>{s.status}</span>
</div>
<div className="flex items-center gap-1">
{s.status === 'active' && (
<button type="button" onClick={() => handleAction(s.workspaceId, 'pause')} aria-label={`Pause ${s.workspaceName}`} className={`p-1 rounded text-muted-foreground hover:text-amber-400 transition-colors ${iconButtonFocus}`}>
<Pause className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
{s.status === 'paused' && (
<button type="button" onClick={() => handleAction(s.workspaceId, 'resume')} aria-label={`Resume ${s.workspaceName}`} className={`p-1 rounded text-muted-foreground hover:text-emerald-400 transition-colors ${iconButtonFocus}`}>
<Play className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
<button type="button" onClick={() => handleAction(s.workspaceId, 'stop')} aria-label={`Stop ${s.workspaceName}`} className={`p-1 rounded text-muted-foreground hover:text-destructive transition-colors ${iconButtonFocus}`}>
<Square className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="flex items-center gap-0.5"><Clock className="w-2.5 h-2.5" />{Math.round((s.duration ?? 0) / 60)}m</span>
<span className="flex items-center gap-0.5"><Zap className="w-2.5 h-2.5" />{s.toolCount ?? 0} tools</span>
<span>{s.model ?? 'default'}</span>
<span>{(s.tokenUsage ?? 0).toLocaleString()} tokens</span>
</div>
</div>
))}
</div>
</>
)}
{tab === 'team' && (
<div className="space-y-2">
{errors.team && teamMembers.length === 0 ? (
<div role="alert" className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="w-10 h-10 text-destructive/50 mb-3" />
<p className="text-sm text-foreground">Couldn't load team members</p>
<p className="text-xs text-muted-foreground/60 max-w-xs">This is a load error, not an empty team. {errors.team}</p>
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs" onClick={refresh}>
<RefreshCw className="w-3.5 h-3.5" /> Retry
</Button>
</div>
) : teamMembers.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Users className="w-10 h-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">No team members</p>
<p className="text-xs text-muted-foreground/60">Connect to a team server in Settings</p>
</div>
)}
{teamMembers.map(m => (
<div key={m.id} className="flex items-center gap-3 p-3 rounded-xl bg-secondary/30 border border-border/30">
<div className={`w-2 h-2 rounded-full ${m.status === 'online' ? 'bg-emerald-400' : 'bg-muted-foreground'}`} />
<span className="text-sm text-foreground flex-1">{m.name}</span>
<span className="text-[11px] text-muted-foreground capitalize">{m.status}</span>
</div>
))}
</div>
)}
{tab === 'activity' && (
<div className="space-y-1.5">
{errors.activity && activity.length === 0 ? (
<div role="alert" className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="w-10 h-10 text-destructive/50 mb-3" />
<p className="text-sm text-foreground">Couldn't load activity</p>
<p className="text-xs text-muted-foreground/60 max-w-xs">This is a load error, not an empty feed. {errors.activity}</p>
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs" onClick={refresh}>
<RefreshCw className="w-3.5 h-3.5" /> Retry
</Button>
</div>
) : activity.length === 0 && (
<div className="text-center py-12">
<p className="text-sm text-muted-foreground">No activity</p>
</div>
)}
{activity.map(a => (
<div key={a.id} className="flex items-center gap-3 p-2 rounded-lg bg-secondary/20 border border-border/20">
<span className="text-xs text-foreground font-display">{a.user}</span>
<span className="text-xs text-muted-foreground flex-1">{a.action}</span>
<span className="text-[11px] text-muted-foreground">{new Date(a.timestamp).toLocaleTimeString(DATE_LOCALE)}</span>
</div>
))}
</div>
)}
</div>
);
};
export default MissionControlApp;

View File

@@ -0,0 +1,99 @@
/**
* PaymentSuccessApp — warm-Hive PR7a Billing (design screen 14 "Success" state).
*
* The post-Stripe-Checkout landing (`/payment-success?session_id=…`, set by
* `checkout.ts:42`). `useBilling` auto-detects `?session_id=` on mount and calls
* POST /api/stripe/sync (payment-gated) → flips the tier. This screen renders the
* confirmation OFF THE SYNCED TIER only.
*
* No-fabrication (recon 04 §3 / plan F5): the design's receipt block (receipt #,
* "Trial ends Jun 28", "Then $19/mo", "Emailed →") has NO data source — `/sync`
* returns only `{ tier, customerId }`. Those rows are GATED OFF here; we never invent
* a charge amount, a trial-end date, or a receipt link.
*/
import { useNavigate } from 'react-router-dom';
import { Check, Loader2, AlertCircle } from 'lucide-react';
import { useBilling } from '@/hooks/useBilling';
const TIER_HEADLINE: Record<string, string> = {
// PRO retained for legacy checkout sessions that predate the Solo/Team split.
PRO: 'Legacy Pro',
TEAMS: 'Team',
ENTERPRISE: 'Enterprise',
};
export default function PaymentSuccessApp() {
const navigate = useNavigate();
const billing = useBilling();
const isPaid = billing.tier === 'PRO' || billing.tier === 'TEAMS' || billing.tier === 'ENTERPRISE';
return (
<div className="h-full overflow-auto">
<div className="max-w-[520px] mx-auto px-8 py-[70px] text-center">
{/* Syncing — the real payment confirmation is in flight */}
{billing.syncing ? (
<div className="flex flex-col items-center gap-4" data-testid="payment-success-syncing">
<Loader2 className="w-9 h-9 animate-spin text-honey" />
<p className="text-[15px] text-[var(--text-2)]">Confirming your payment</p>
</div>
) : billing.error ? (
/* Honest failure — never fake a success (F10/F5) */
<div className="flex flex-col items-center gap-4" data-testid="payment-success-error">
<div className="w-[78px] h-[78px] rounded-full grid place-items-center bg-destructive/10 border border-destructive/30">
<AlertCircle className="w-9 h-9 text-destructive" strokeWidth={2.2} />
</div>
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-foreground">We couldnt confirm the payment</h1>
<p className="text-[14px] text-[var(--text-muted)] max-w-[42ch]">{billing.error}</p>
<button
onClick={() => navigate('/settings?tab=billing')}
className="mt-2 px-[22px] py-3 rounded-[11px] text-[14.5px] font-[650] bg-[var(--surface)] text-[var(--text-2)] border border-[var(--line-strong)] hover:border-[var(--honey-line)] hover:text-honey transition-colors"
>
Back to plans
</button>
</div>
) : isPaid ? (
/* Confirmation off the SYNCED tier — no receipt rows (F5) */
<>
<div className="w-[78px] h-[78px] rounded-full mx-auto mb-6 grid place-items-center bg-[var(--healthy-wash)] border border-[color:color-mix(in_srgb,var(--healthy)_40%,transparent)]">
<Check className="w-9 h-9 text-[var(--healthy)]" strokeWidth={2.2} />
</div>
<h1 className="text-[28px] font-[650] tracking-[-0.02em] mb-3 text-foreground">
Youre <span className="text-honey">{TIER_HEADLINE[billing.tier] ?? billing.tier}.</span>
</h1>
<p className="text-[15px] text-[var(--text-2)] leading-relaxed mx-auto mb-7 max-w-[42ch]">
Your hive just leveled up <b className="text-foreground">shared team memory, cross-device sync, and governance</b> are live.
</p>
<div className="inline-flex gap-2.5">
<button
onClick={() => navigate('/home')}
className="px-[22px] py-3 rounded-[11px] text-[14.5px] font-[650] bg-primary text-primary-foreground hover:bg-[var(--honey-bright)] transition-colors"
>
Start using {TIER_HEADLINE[billing.tier] ?? billing.tier}
</button>
<button
onClick={() => navigate('/settings?tab=billing')}
className="px-[22px] py-3 rounded-[11px] text-[14.5px] font-[650] bg-[var(--surface)] text-[var(--text-2)] border border-[var(--line-strong)] hover:border-[var(--honey-line)] hover:text-honey transition-colors"
>
Manage billing
</button>
</div>
</>
) : (
/* Synced but not on a paid tier (cancelled / unpaid) — honest, no fake success */
<div className="flex flex-col items-center gap-4" data-testid="payment-success-unpaid">
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-foreground">Nothing to confirm</h1>
<p className="text-[14px] text-[var(--text-muted)] max-w-[42ch]">
We didnt detect a completed checkout. If you just paid, give it a moment and refresh.
</p>
<button
onClick={() => navigate('/settings?tab=billing')}
className="mt-2 px-[22px] py-3 rounded-[11px] text-[14.5px] font-[650] bg-[var(--surface)] text-[var(--text-2)] border border-[var(--line-strong)] hover:border-[var(--honey-line)] hover:text-honey transition-colors"
>
Back to plans
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
/**
* PlatformApp — Warm-Hive "Platform & roadmap" showcase (screen 18).
* Pure-UI, static-data surface: assert the three showcase tabs render their
* key headings/labels, that the segmented toggle switches views, and that the
* macOS ↔ Windows title-bar toggle flips inside the Desktop view.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import PlatformApp from './PlatformApp';
afterEach(() => {
cleanup();
});
describe('PlatformApp', () => {
it('renders the three showcase tabs and lands on Desktop (now)', () => {
render(<PlatformApp />);
const tablist = screen.getByRole('tablist', { name: /platform showcase/i });
expect(within(tablist).getByRole('tab', { name: /desktop \(now\)/i })).toBeInTheDocument();
expect(within(tablist).getByRole('tab', { name: /^boot$/i })).toBeInTheDocument();
expect(within(tablist).getByRole('tab', { name: /coming next/i })).toBeInTheDocument();
// Default tab = Desktop, selected + content visible.
expect(within(tablist).getByRole('tab', { name: /desktop \(now\)/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: /a real desktop app\./i })).toBeInTheDocument();
expect(screen.getByText('~12 MB')).toBeInTheDocument();
expect(screen.getByText('Platform & roadmap')).toHaveClass('text-[var(--text-muted)]');
});
it('switches to the Boot showcase tab', () => {
render(<PlatformApp />);
fireEvent.click(screen.getByRole('tab', { name: /^boot$/i }));
expect(screen.getByRole('tab', { name: /^boot$/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: /warming the hive/i })).toBeInTheDocument();
expect(screen.getByText(/local model online · qwen 2\.5/i)).toBeInTheDocument();
// Desktop content is gone.
expect(screen.queryByText('~12 MB')).not.toBeInTheDocument();
});
it('switches to the Coming next roadmap tab and lists the channels', () => {
render(<PlatformApp />);
fireEvent.click(screen.getByRole('tab', { name: /coming next/i }));
expect(screen.getByRole('heading', { name: /one hive, everywhere you work\./i })).toBeInTheDocument();
expect(screen.getByText('Browser extension')).toBeInTheDocument();
expect(screen.getByText('Messaging')).toBeInTheDocument();
expect(screen.getByText('Mobile')).toBeInTheDocument();
expect(screen.getByText(/available now/i)).toBeInTheDocument();
expect(screen.getByText(/in beta/i)).toBeInTheDocument();
});
it('toggles the macOS ↔ Windows title-bar in the Desktop view', () => {
render(<PlatformApp />);
const osGroup = screen.getByRole('group', { name: /operating system/i });
const macBtn = within(osGroup).getByRole('button', { name: /macos/i });
const winBtn = within(osGroup).getByRole('button', { name: /windows/i });
expect(macBtn).toHaveAttribute('aria-pressed', 'true');
expect(winBtn).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(winBtn);
expect(winBtn).toHaveAttribute('aria-pressed', 'true');
expect(macBtn).toHaveAttribute('aria-pressed', 'false');
});
});

View File

@@ -0,0 +1,363 @@
/**
* PlatformApp — Warm-Hive "Platform & roadmap" showcase (screen 18).
*
* Three static, pure-UI showcase tabs behind a segmented toggle:
* 1. Desktop (now) — a desktop-window mock with a macOS ↔ Windows title-bar
* toggle, a mini app inside, and the install spec tiles.
* 2. Boot — a STATIC boot-screen showcase (D6: NOT wired to the real
* AppShell / BootScreen — this is a marketing mock only).
* 3. Coming next — the surface roadmap as channel cards.
*
* No backend, no API, no props (⌘K-only surface). The standalone NotFound route
* (pages/NotFound.tsx) is the real 404 — per D5 it is NOT a tab here.
*
* Warm tokens only (D21): honey via `primary` / `--honey-*`, neutrals via the
* warm `--text*` / `--surface*` / `--line*` CSS vars (the warm-hive convention,
* matching WorkspaceDesktopApp). No raw emerald/violet/amber/sky.
*/
import { useState, type ReactNode } from 'react';
import {
Monitor, Puzzle, MessageCircle, Smartphone, Check, Loader2,
} from 'lucide-react';
type Tab = 'desktop' | 'boot' | 'roadmap';
type Os = 'mac' | 'win';
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: 'desktop', label: 'Desktop (now)' },
{ id: 'boot', label: 'Boot' },
{ id: 'roadmap', label: 'Coming next' },
];
interface Spec {
value: string;
label: string;
}
const SPECS: ReadonlyArray<Spec> = [
{ value: '~12 MB', label: 'Install size' },
{ value: 'Local', label: 'Data on your disk' },
{ value: 'Auto-update', label: 'Signed & notarized' },
{ value: 'Win · Mac', label: 'Linux soon' },
];
interface BootStep {
text: string;
state: 'done' | 'active' | 'pending';
}
const BOOT_STEPS: ReadonlyArray<BootStep> = [
{ text: 'Memory engine ready · 581 memories', state: 'done' },
{ text: 'Local model online · Qwen 2.5', state: 'done' },
{ text: 'Loading workspaces… 8 found', state: 'active' },
{ text: 'Restoring your last session', state: 'pending' },
];
type ChannelStatus = 'now' | 'beta' | 'next';
interface Channel {
icon: typeof Monitor;
title: string;
blurb: string;
platforms: ReadonlyArray<string>;
status: ChannelStatus;
statusLabel: string;
/** Icon-tile tint — per-channel per design (platform.html:160-175): honey/work/intel-wash. */
tint: string;
}
const CHANNELS: ReadonlyArray<Channel> = [
{
icon: Monitor,
title: 'Desktop app',
blurb: 'The full hive — native, local, fast.',
platforms: ['macOS', 'Windows', 'Linux soon'],
status: 'now',
statusLabel: 'Available now',
tint: 'var(--honey-wash)',
},
{
icon: Puzzle,
title: 'Browser extension',
blurb: 'Capture context & recall memory anywhere on the web.',
platforms: ['Chrome', 'Edge', 'Firefox'],
status: 'beta',
statusLabel: 'In beta',
tint: 'var(--work-wash)',
},
{
icon: MessageCircle,
title: 'Messaging',
blurb: 'Talk to your hive from where you already chat — same memory, same agents.',
platforms: ['WhatsApp', 'Telegram', 'iMessage'],
status: 'next',
statusLabel: 'Coming next',
tint: 'var(--honey-wash)',
},
{
icon: Smartphone,
title: 'Mobile',
blurb: 'Your morning briefing and quick capture, in your pocket.',
platforms: ['iOS', 'Android'],
status: 'next',
statusLabel: 'Coming next',
tint: 'var(--intel-wash)',
},
];
/** Warm status pill colors (D21 — sage / dusty-blue / honey via warm vars). */
const STATUS_STYLE: Record<ChannelStatus, string> = {
now: 'text-[var(--healthy)] bg-[var(--healthy-wash)]',
beta: 'text-[var(--work)] bg-[var(--work-wash)]',
next: 'text-[var(--attention)] bg-[var(--honey-wash)]',
};
const STATUS_GLYPH: Record<ChannelStatus, string> = { now: '●', beta: '◐', next: '' };
function PlatformApp() {
const [tab, setTab] = useState<Tab>('desktop');
const [os, setOs] = useState<Os>('mac');
return (
<div className="flex h-full flex-col bg-background text-foreground">
{/* Controls — segmented toggle */}
<div className="flex flex-none items-center gap-3.5 border-b border-[var(--line-soft)] px-5 py-2.5">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-[var(--text-muted)]">
Platform &amp; roadmap
</span>
<div role="tablist" aria-label="Platform showcase" className="inline-flex gap-[3px] rounded-[10px] border border-[var(--line-soft)] bg-[var(--surface-2)] p-[3px]">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`whitespace-nowrap rounded-[7px] px-3 py-1.5 text-xs font-semibold transition-colors ${
tab === t.id
? 'bg-primary text-[#1a1407]'
: 'text-[var(--text-muted)] hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Stage */}
<div className="relative min-h-0 flex-1 overflow-auto">
{tab === 'desktop' && <DesktopView os={os} onOsChange={setOs} />}
{tab === 'boot' && <BootView />}
{tab === 'roadmap' && <RoadmapView />}
</div>
</div>
);
}
interface SectionHeadProps {
eyebrow: string;
title: ReactNode;
blurb: ReactNode;
}
function SectionHead({ eyebrow, title, blurb }: SectionHeadProps) {
return (
<div className="mb-6 text-center">
<div className="mb-3 font-mono text-[11px] uppercase tracking-[0.14em] text-honey">{eyebrow}</div>
<h1 className="mb-2.5 font-display text-[28px] font-semibold leading-tight tracking-[-0.02em]">{title}</h1>
<p className="mx-auto max-w-[54ch] text-[15px] leading-relaxed text-[var(--text-muted)]">{blurb}</p>
</div>
);
}
interface DesktopViewProps {
os: Os;
onOsChange: (os: Os) => void;
}
function DesktopView({ os, onOsChange }: DesktopViewProps) {
return (
<div role="tabpanel" aria-label="Desktop (now)" className="mx-auto max-w-[920px] px-8 pb-14 pt-7">
<SectionHead
eyebrow="Ships now · Tauri"
title={<>A real <em className="not-italic text-honey">desktop app.</em></>}
blurb={
<>
Waggle ships as a native app for <b className="text-[var(--text-2)]">Windows &amp; macOS</b> built on
Tauri, so it&rsquo;s tiny, fast, and truly local. Your hive lives on your disk; the app just opens a
window onto it.
</>
}
/>
<div className="mx-auto max-w-[760px]">
{/* OS toggle */}
<div role="group" aria-label="Operating system" className="mb-4 flex justify-center gap-1.5">
{(['mac', 'win'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={os === value}
onClick={() => onOsChange(value)}
className={`rounded-[9px] border px-3.5 py-1.5 text-[12.5px] font-semibold transition-colors ${
os === value
? 'border-[var(--honey-line)] bg-[var(--honey-wash)] text-honey'
: 'border-[var(--line-soft)] bg-[var(--surface)] text-[var(--text-muted)] hover:text-foreground'
}`}
>
{value === 'mac' ? 'macOS' : 'Windows'}
</button>
))}
</div>
{/* Window mock */}
<div className="overflow-hidden rounded-xl border border-[var(--line-strong)] bg-[var(--bg-2)] shadow-[var(--shadow-lg)]">
<div
className={`flex h-[38px] items-center gap-2 border-b border-[var(--line-soft)] bg-[var(--surface)] px-3.5 ${
os === 'win' ? 'flex-row-reverse' : ''
}`}
>
{os === 'mac' ? (
<div className="flex gap-2" aria-hidden>
<span className="size-3 rounded-full bg-[#ec6a5e]" />
<span className="size-3 rounded-full bg-[#f4bf4f]" />
<span className="size-3 rounded-full bg-[#61c554]" />
</div>
) : (
<div className="flex" aria-hidden>
<span className="grid h-[38px] w-[30px] place-items-center text-xs text-[var(--text-muted)]">&#8213;</span>
<span className="grid h-[38px] w-[30px] place-items-center text-xs text-[var(--text-muted)]">&#9634;</span>
<span className="grid h-[38px] w-[30px] place-items-center text-xs text-[var(--text-muted)]">&#10005;</span>
</div>
)}
<div className="flex-1 text-center font-mono text-xs text-[var(--text-muted)]">Waggle</div>
</div>
<div className="flex sm:h-[300px]">
{/* Mini sidebar */}
<div className="w-[120px] flex-none border-r border-[var(--line-soft)] bg-[var(--bg-2)] px-2 py-3.5" aria-hidden>
{[
{ label: 'Home', on: true },
{ label: 'Chat', on: false },
{ label: 'Memory', on: false },
{ label: 'Agents', on: false },
{ label: 'Library', on: false },
].map((item) => (
<div
key={item.label}
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-xs ${
item.on ? 'bg-[var(--honey-wash)] text-foreground' : 'text-[var(--text-2)]'
}`}
>
<span className={`size-[13px] rounded ${item.on ? 'bg-primary' : 'bg-[var(--surface-3)]'}`} />
{item.label}
</div>
))}
</div>
{/* Mini main */}
<div className="flex-1 bg-background p-[22px]" aria-hidden>
<div className="font-mono text-[10px] tracking-[0.08em] text-honey">FRIDAY · 8:42</div>
<h3 className="mb-3.5 mt-2 font-display text-xl font-semibold tracking-[-0.02em]">Good morning, Mara.</h3>
<div className="mb-2.5 h-[9px] rounded bg-[var(--surface-2)]" />
<div className="mb-2.5 h-[9px] rounded bg-[var(--surface-2)]" />
<div className="mb-2.5 h-[9px] w-3/5 rounded bg-[var(--surface-2)]" />
</div>
</div>
</div>
</div>
{/* Spec tiles */}
<div className="mt-[22px] grid grid-cols-2 gap-3 md:grid-cols-4">
{SPECS.map((s) => (
<div key={s.label} className="rounded-[var(--r)] border border-[var(--line-soft)] bg-[var(--surface)] p-4 text-center">
<div className="text-base font-bold tracking-[-0.01em]">{s.value}</div>
<div className="mt-1.5 text-[11px] text-[var(--text-muted)]">{s.label}</div>
</div>
))}
</div>
</div>
);
}
function BootView() {
return (
<div role="tabpanel" aria-label="Boot" className="mx-auto max-w-[920px] px-8 pb-14 pt-7">
<div className="flex min-h-[440px] flex-col items-center justify-center text-center">
<div
className="boot-hex-pulse mb-6 grid h-20 w-[72px] place-items-center text-[30px] font-extrabold text-primary-foreground"
style={{
background: 'linear-gradient(150deg, var(--honey-bright), var(--honey-deep))',
clipPath: 'polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%)',
}}
aria-hidden
>
W
</div>
<h1 className="mb-2 whitespace-nowrap font-display text-2xl font-semibold tracking-[-0.02em]">
Warming the hive
</h1>
<div className="mb-6 font-mono text-xs text-[var(--text-dim)]">waggle · v1.0 · local</div>
<ul className="grid w-[300px] gap-2 text-left">
{BOOT_STEPS.map((step) => (
<li
key={step.text}
className={`flex items-center gap-2.5 text-[13px] ${
step.state === 'pending' ? 'text-[var(--text-dim)]' : 'text-[var(--text-2)]'
}`}
>
<span className="flex size-4 flex-none items-center justify-center" aria-hidden>
{step.state === 'done' && <Check className="size-4 text-[var(--healthy)]" strokeWidth={2.4} />}
{step.state === 'active' && <Loader2 className="size-[13px] animate-spin text-honey" />}
</span>
{step.text}
</li>
))}
</ul>
</div>
</div>
);
}
function RoadmapView() {
return (
<div role="tabpanel" aria-label="Coming next" className="mx-auto max-w-[920px] px-8 pb-14 pt-7">
<SectionHead
eyebrow="Where Waggle goes"
title={<>One hive, <em className="not-italic text-honey">everywhere you work.</em></>}
blurb="Your memory is the constant; the surfaces multiply. Desktop today — your phone and your messaging apps next."
/>
<div className="grid gap-3">
{CHANNELS.map((ch) => {
const Icon = ch.icon;
return (
<div
key={ch.title}
className={`flex items-center gap-4 rounded-[var(--r-lg)] border bg-[var(--surface)] px-5 py-4 ${
ch.status === 'now' ? 'border-[var(--honey-line)]' : 'border-[var(--line-soft)]'
}`}
>
<div className="grid size-11 flex-none place-items-center rounded-[var(--r)]" style={{ background: ch.tint }}>
<Icon className="size-[22px] text-honey" strokeWidth={1.7} aria-hidden />
</div>
<div className="flex-1">
<b className="text-[15.5px] font-semibold">{ch.title}</b>
<p className="mt-0.5 text-[13px] text-[var(--text-muted)]">{ch.blurb}</p>
<div className="mt-2.5 flex flex-wrap gap-1.5">
{ch.platforms.map((p) => (
<span
key={p}
className="rounded-md border border-[var(--line-soft)] px-2 py-0.5 font-mono text-[10.5px] text-[var(--text-dim)]"
>
{p}
</span>
))}
</div>
</div>
<span className={`whitespace-nowrap rounded-full px-3 py-1.5 text-[11.5px] font-semibold ${STATUS_STYLE[ch.status]}`}>
{STATUS_GLYPH[ch.status] && <span aria-hidden>{STATUS_GLYPH[ch.status]} </span>}
{ch.statusLabel}
</span>
</div>
);
})}
</div>
</div>
);
}
export default PlatformApp;

View File

@@ -0,0 +1,321 @@
/**
* RoomApp — PR6b §16 two-column shared stage + participants panel. The test
* mocks useRoomState (the SSE hook) so the render path is exercised without a
* transport, and asserts: (1) the connecting/empty/error full-bleed states,
* (2) the two-column layout appears once there is a roster, (3) the
* participants panel is derived from the SAME real roster (host + live + recent),
* never fabricated (D20).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, within, fireEvent, waitFor } from '@testing-library/react';
import type { RoomAgent } from '@/lib/room-state-reducer';
import type { CollaborationRoomRun, CollaborationWorkerRun } from '@waggle/shared';
interface MockRoomState {
workspaceMap: Map<string, { live: RoomAgent[]; recent: RoomAgent[]; lastUpdatedAt: number }>;
totalLive: number;
connecting: boolean;
error: string | null;
reconnect: ReturnType<typeof vi.fn>;
focusedRoom?: CollaborationRoomRun;
focusedWorkers?: CollaborationWorkerRun[];
}
const state = vi.hoisted(() => ({
value: {
workspaceMap: new Map(),
totalLive: 0,
connecting: false,
error: null as string | null,
reconnect: vi.fn(),
} as MockRoomState,
}));
const mocks = vi.hoisted(() => ({ adapter: { controlAgentRun: vi.fn() } }));
vi.mock('@/hooks/useRoomState', () => ({
useRoomState: () => state.value,
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
import RoomApp from './RoomApp';
function agent(over: Partial<RoomAgent>): RoomAgent {
return {
id: 'a1',
name: 'Research-synth',
role: 'researcher',
status: 'running',
task: 'Pulling the strongest proof points from memory',
toolsUsed: ['recall_memory'],
startedAt: Date.now() - 5000,
...over,
};
}
function setRoster(live: RoomAgent[], recent: RoomAgent[] = []) {
const map = new Map();
map.set('ws1', { live, recent, lastUpdatedAt: Date.now() });
state.value = {
workspaceMap: map,
totalLive: live.length,
connecting: false,
error: null,
reconnect: vi.fn(),
};
}
const RUN_AT = '2026-07-11T00:00:00.000Z';
function canonicalRoom(over: Partial<CollaborationRoomRun> = {}): CollaborationRoomRun {
return {
schemaVersion: 1,
kind: 'room',
id: 'room-campaign',
roomId: 'room-campaign',
rootRunId: 'room-campaign',
parentRunId: null,
workspaceIds: ['ws1'],
source: 'external_tool',
executor: { kind: 'coordinator', toolId: 'codex' },
title: 'Campaign Room',
task: 'Compare the two campaign implementations',
status: 'running',
progress: { message: 'Coordinating workers', phase: 'fan-out', current: 1, total: 2 },
memoryRefs: { status: 'pending', personalFrameIds: [], workspaceFrameIds: {} },
capabilities: { cancel: true, pause: true, resume: false, message: true },
revision: 1,
createdAt: RUN_AT,
updatedAt: RUN_AT,
startedAt: RUN_AT,
...over,
};
}
function canonicalWorker(over: Partial<CollaborationWorkerRun> = {}): CollaborationWorkerRun {
return {
schemaVersion: 1,
kind: 'worker',
id: 'run-alpha',
roomId: 'room-campaign',
rootRunId: 'room-campaign',
parentRunId: 'room-campaign',
workspaceId: 'ws1',
source: 'external_tool',
executor: { kind: 'external_tool', toolId: 'codex', model: 'gpt-test', pid: 42 },
title: 'Worker Alpha',
task: 'Review the Alpha workspace',
status: 'paused',
progress: { message: 'Reviewing source', phase: 'analysis', current: 2, total: 4 },
result: {
summary: 'Found a partial implementation',
error: 'One source file was unavailable',
sessionId: 'session-alpha',
traceId: 'trace-alpha',
artifacts: ['reports/alpha.md'],
exitCode: null,
},
metrics: { toolsUsed: ['read_file'], inputTokens: 10, outputTokens: 20, costUsd: 0.01 },
memoryRefs: {
status: 'partial',
personalFrameIds: [11],
workspaceFrameIds: { ws1: [21, 22] },
},
capabilities: { cancel: true, pause: false, resume: true, message: true },
revision: 1,
createdAt: RUN_AT,
updatedAt: RUN_AT,
startedAt: RUN_AT,
...over,
};
}
function setFocusedRoom(room: CollaborationRoomRun, workers: CollaborationWorkerRun[]) {
const map = new Map<string, { live: RoomAgent[]; recent: RoomAgent[]; lastUpdatedAt: number }>();
for (const run of workers) {
const current = map.get(run.workspaceId) ?? { live: [], recent: [], lastUpdatedAt: Date.now() };
const projected = agent({
id: run.id,
name: run.title,
role: run.executor.toolId ?? run.source,
status: ['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status) ? 'done' : 'running',
task: run.task,
origin: 'run',
roomId: run.roomId,
runStatus: run.status,
});
if (['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status)) current.recent.push(projected);
else current.live.push(projected);
map.set(run.workspaceId, current);
}
state.value = {
workspaceMap: map,
totalLive: workers.filter((run) => !['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status)).length,
connecting: false,
error: null,
reconnect: vi.fn(),
focusedRoom: room,
focusedWorkers: workers,
};
}
beforeEach(() => {
mocks.adapter.controlAgentRun.mockReset();
state.value = {
workspaceMap: new Map(),
totalLive: 0,
connecting: false,
error: null,
reconnect: vi.fn(),
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('RoomApp', () => {
it('shows the empty "no agents running" state when the roster is empty', () => {
render(<RoomApp />);
// The empty-state body carries a distinctive explainer (the header counter
// also says "no agents running", so we assert the body copy here).
expect(screen.getByText(/spawn a Waggle agent.*captured external task/i)).toBeInTheDocument();
// No two-column stage/participants chrome when empty.
expect(screen.queryByTestId('room-participants')).not.toBeInTheDocument();
expect(screen.queryByTestId('room-stage')).not.toBeInTheDocument();
});
it('shows the connecting state before the stage appears', () => {
state.value = { ...state.value, connecting: true };
render(<RoomApp />);
expect(screen.getByTestId('room-connecting')).toBeInTheDocument();
expect(screen.queryByTestId('room-stage')).not.toBeInTheDocument();
});
it('renders the reconnect affordance on an SSE error, not an empty room', () => {
state.value = { ...state.value, error: 'stream closed' };
render(<RoomApp />);
expect(screen.getByRole('alert')).toHaveTextContent(/disconnected/i);
expect(screen.getByRole('button', { name: /reconnect/i })).toBeInTheDocument();
// The error branch wins over the empty-state body (B2): no stage, no
// "ask a complex question" explainer — just the connection error.
expect(screen.queryByTestId('room-stage')).not.toBeInTheDocument();
expect(screen.queryByText(/spawn a Waggle agent.*captured external task/i)).not.toBeInTheDocument();
});
it('renders the two-column stage + participants panel once a roster exists', () => {
setRoster([agent({ id: 'a1', name: 'Research-synth' })]);
render(<RoomApp />);
const stage = screen.getByTestId('room-stage');
expect(stage).toBeInTheDocument();
expect(screen.getByTestId('room-participants')).toBeInTheDocument();
// The agent tile is on the stage (it also appears in the participants
// panel by design — scope to the stage to assert the tile specifically).
expect(within(stage).getByText('Research-synth')).toBeInTheDocument();
});
it('derives the participants panel from the real roster (host + live + recent), never fabricated', () => {
setRoster(
[agent({ id: 'live1', name: 'Deck-builder', role: 'writer', status: 'running' })],
[agent({ id: 'done1', name: 'Analyst', role: 'analyst', status: 'done', completedAt: Date.now() })],
);
render(<RoomApp />);
const panel = screen.getByTestId('room-participants');
// Host is always present.
expect(within(panel).getByText('You')).toBeInTheDocument();
expect(within(panel).getByText('host')).toBeInTheDocument();
// The live agent shows as "live"; the recently-finished agent shows by role.
expect(within(panel).getByText('Deck-builder')).toBeInTheDocument();
expect(within(panel).getByText('Analyst')).toBeInTheDocument();
// One participant row per real roster agent (+ none invented). Two agents → 2 rows.
expect(within(panel).getAllByTestId('room-participant')).toHaveLength(2);
});
});
describe('RoomApp canonical Room presentation and controls', () => {
it('renders the root aggregate and complete exact worker details', () => {
const room = canonicalRoom();
const worker = canonicalWorker();
setFocusedRoom(room, [worker]);
render(<RoomApp roomId={room.id} workspaceNames={{ ws1: 'Alpha Workspace' }} />);
const summary = screen.getByTestId('room-root-summary');
expect(summary).toHaveTextContent('Campaign Room');
expect(summary).toHaveTextContent('Running');
expect(summary).toHaveTextContent('0/1 settled');
expect(summary).toHaveTextContent('Alpha Workspace');
const card = screen.getByTestId('canonical-worker-card');
expect(card).toHaveAttribute('data-run-status', 'paused');
expect(card).toHaveTextContent('Paused');
expect(card).toHaveTextContent('analysis · Reviewing source');
expect(card).toHaveTextContent('Found a partial implementation');
expect(card).toHaveTextContent('One source file was unavailable');
expect(card).toHaveTextContent('reports/alpha.md');
expect(card).toHaveTextContent('Session · session-alpha');
expect(card).toHaveTextContent('Trace · trace-alpha');
expect(card).toHaveTextContent('Tools · read_file');
expect(card).toHaveTextContent('Input · 10 tokens');
expect(card).toHaveTextContent('Output · 20 tokens');
expect(card).toHaveTextContent('Cost · $0.0100');
expect(card).toHaveTextContent('Memory · partial');
expect(card).toHaveTextContent('Personal mind · 1 frame');
expect(card).toHaveTextContent('Workspace mind · 2 frames');
});
it('sends only capability-gated root and worker controls through the canonical API', async () => {
const room = canonicalRoom();
const worker = canonicalWorker();
setFocusedRoom(room, [worker]);
mocks.adapter.controlAgentRun.mockResolvedValue(room);
render(<RoomApp roomId={room.id} workspaceNames={{ ws1: 'Alpha Workspace' }} />);
fireEvent.click(screen.getByRole('button', { name: 'Pause Campaign Room' }));
await waitFor(() => expect(mocks.adapter.controlAgentRun).toHaveBeenCalledWith(room.id, 'pause', undefined));
fireEvent.click(screen.getByRole('button', { name: 'Resume Worker Alpha' }));
await waitFor(() => expect(mocks.adapter.controlAgentRun).toHaveBeenCalledWith(worker.id, 'resume', undefined));
const workerCard = screen.getByTestId('canonical-worker-card');
fireEvent.change(within(workerCard).getByRole('textbox', { name: 'Message Worker Alpha' }), {
target: { value: 'Continue with the available files' },
});
fireEvent.click(within(workerCard).getByRole('button', { name: 'Send message to Worker Alpha' }));
await waitFor(() => expect(mocks.adapter.controlAgentRun).toHaveBeenCalledWith(
worker.id,
'message',
'Continue with the available files',
));
fireEvent.click(screen.getByRole('button', { name: 'Cancel Campaign Room' }));
await waitFor(() => expect(mocks.adapter.controlAgentRun).toHaveBeenCalledWith(room.id, 'cancel', undefined));
expect(screen.queryByRole('button', { name: 'Pause Worker Alpha' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resume Campaign Room' })).not.toBeInTheDocument();
});
it('hides controls when a terminal worker advertises no capabilities', () => {
const room = canonicalRoom({ status: 'completed', capabilities: { cancel: false, pause: false, resume: false, message: false } });
const worker = canonicalWorker({
id: 'run-finished',
title: 'Finished Worker',
status: 'completed',
completedAt: RUN_AT,
capabilities: { cancel: false, pause: false, resume: false, message: false },
});
setFocusedRoom(room, [worker]);
render(<RoomApp roomId={room.id} workspaceNames={{ ws1: 'Alpha Workspace' }} />);
expect(screen.getByTestId('canonical-worker-card')).toHaveAttribute('data-run-status', 'completed');
expect(screen.queryByTestId(`run-controls-${worker.id}`)).not.toBeInTheDocument();
expect(mocks.adapter.controlAgentRun).not.toHaveBeenCalled();
});
it('renders an explicit missing-room state for an invalid deep link', () => {
render(<RoomApp roomId="missing-room" />);
expect(screen.getByTestId('room-not-found')).toHaveTextContent('Room not found');
expect(screen.getByTestId('room-not-found')).toHaveTextContent('missing-room');
expect(screen.queryByText('No agents running')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,737 @@
/**
* RoomApp — the Room canvas from Phase A.3 of the Killer Story plan.
*
* Shows every running sub-agent across every workspace as a tile on a
* canvas, so the user literally watches their team of specialists work.
* Subscribes to `subagent_status` SSE events via useRoomState.
*
* Layout (PR6b §16 — two-column shared stage + participants panel):
* ┌───────────────────────────────────────────────────────┐
* │ Header: live count, recent count │
* ├──────────────────────────────────┬────────────────────┤
* │ Turn stage │ In the room │
* │ Live agents (grid of tiles) │ participant list │
* │ Recently completed (collapse) │ (host + agents) │
* └──────────────────────────────────┴────────────────────┘
*/
import { useMemo, useState } from 'react';
import {
Users, CheckCircle2, AlertCircle, Loader2, Clock, Wrench,
Pause, Play, Square, Send, Brain, FileText,
} from 'lucide-react';
import { useRoomState, type RoomAgent } from '@/hooks/useRoomState';
import { adapter } from '@/lib/adapter';
import type {
CollaborationRun,
CollaborationRunControl,
CollaborationRunStatus,
CollaborationRoomRun,
CollaborationWorkerRun,
} from '@waggle/shared';
interface RoomAppProps {
/** Optional workspace filter — if set, only shows agents for that workspace. */
workspaceId?: string;
roomId?: string;
/** Map of workspace IDs to human-readable names. */
workspaceNames?: Record<string, string>;
}
// Warm-token status styling (D21): the live SSE roster maps to semantic vars,
// not raw emerald/sky/violet. Inline style is the project convention for the
// --work/--intel/--healthy/--risk semantic palette (see BenchmarkApp).
const STATUS_DOT_STYLE: Record<RoomAgent['status'], { background: string }> = {
pending: { background: 'var(--text-dim)' },
running: { background: 'var(--honey)' },
done: { background: 'var(--healthy)' },
failed: { background: 'var(--risk)' },
};
const TERMINAL_RUN_STATUSES = new Set<CollaborationRunStatus>([
'completed', 'failed', 'cancelled', 'interrupted',
]);
function isTerminalRun(status: CollaborationRunStatus): boolean {
return TERMINAL_RUN_STATUSES.has(status);
}
function runStatusLabel(status: CollaborationRunStatus): string {
const label = status.replace(/_/g, ' ');
return `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
}
function runStatusStyle(status: CollaborationRunStatus): { color: string; background: string } {
if (status === 'completed') return { color: 'var(--healthy)', background: 'var(--healthy-wash)' };
if (status === 'failed' || status === 'interrupted') return { color: 'var(--risk)', background: 'var(--risk-wash)' };
if (status === 'cancelled') return { color: 'var(--text-dim)', background: 'var(--surface-2)' };
if (status === 'waiting_for_approval' || status === 'paused' || status === 'cancelling') {
return { color: 'var(--attention)', background: 'var(--honey-wash)' };
}
return { color: 'var(--honey)', background: 'var(--honey-wash)' };
}
// Persona role → warm tint. Roles fan out across the four semantic hues so the
// stage reads at a glance without reintroducing the saturated tier colors.
const ROLE_STYLE: Record<string, { color: string; background: string }> = {
researcher: { color: 'var(--work)', background: 'var(--work-wash)' },
writer: { color: 'var(--attention)', background: 'var(--honey-wash)' },
coder: { color: 'var(--intel)', background: 'var(--intel-wash)' },
analyst: { color: 'var(--healthy)', background: 'var(--healthy-wash)' },
reviewer: { color: 'var(--risk)', background: 'var(--risk-wash)' },
planner: { color: 'var(--work)', background: 'var(--work-wash)' },
};
function roleStyle(role: string): { color: string; background: string } {
return ROLE_STYLE[role.toLowerCase()] ?? { color: 'var(--text-dim)', background: 'var(--surface-2)' };
}
function formatElapsed(startedAt?: number, completedAt?: number): string {
if (!startedAt) return '';
const end = completedAt ?? Date.now();
const seconds = Math.round((end - startedAt) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
return `${hours}h`;
}
function AgentTile({ agent, workspaceName }: { agent: RoomAgent; workspaceName?: string }) {
const currentTool = agent.toolsUsed[agent.toolsUsed.length - 1];
const elapsed = formatElapsed(agent.startedAt, agent.completedAt);
return (
<div
className="p-3 rounded-xl bg-secondary/30 border border-border/30 hover:border-primary/30 transition-colors min-w-0"
data-testid="room-agent-tile"
data-agent-id={agent.id}
data-agent-status={agent.status}
data-agent-role={agent.role}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-2 h-2 rounded-full shrink-0 ${agent.status === 'running' ? 'animate-pulse' : ''}`}
style={STATUS_DOT_STYLE[agent.status]}
/>
<span
className="px-1.5 py-0.5 rounded text-[10px] font-display uppercase tracking-wide"
style={roleStyle(agent.role)}
>
{agent.role}
</span>
<span className="text-xs font-display text-foreground truncate">{agent.name}</span>
</div>
{elapsed && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
<Clock className="w-2.5 h-2.5" />
<span>{elapsed}</span>
</div>
)}
</div>
<p className="text-[11px] text-muted-foreground mb-2 line-clamp-2">{agent.task}</p>
{workspaceName && (
<p className="text-[10px] text-muted-foreground/60 mb-2">workspace · {workspaceName}</p>
)}
{(agent.status === 'running' || agent.status === 'pending') && currentTool && (
<div className="flex items-center gap-1.5 text-[11px]">
<Wrench className="w-3 h-3 text-honey/70" />
<span className="text-honey/90 font-mono truncate">{currentTool}</span>
</div>
)}
{agent.status === 'done' && (
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: 'var(--healthy)' }}>
<CheckCircle2 className="w-3 h-3" />
<span>Done · {agent.toolsUsed.length} tool{agent.toolsUsed.length === 1 ? '' : 's'} used</span>
</div>
)}
{agent.status === 'failed' && (
<div className="flex items-center gap-1.5 text-[11px] text-destructive">
<AlertCircle className="w-3 h-3" />
<span>Failed</span>
</div>
)}
{agent.status === 'pending' && !currentTool && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Queued</span>
</div>
)}
</div>
);
}
interface ControlState {
runId: string;
action: CollaborationRunControl;
}
interface RunControlProps {
run: CollaborationRun;
pending: ControlState | null;
error?: string;
message: string;
onMessageChange: (message: string) => void;
onControl: (action: CollaborationRunControl, message?: string) => void;
}
function RunControls({ run, pending, error, message, onMessageChange, onControl }: RunControlProps) {
const busy = pending?.runId === run.id;
const terminal = isTerminalRun(run.status);
const canCancel = run.capabilities.cancel && !terminal && run.status !== 'cancelling';
const canPause = run.capabilities.pause && run.status === 'running';
const canResume = run.capabilities.resume && run.status === 'paused';
const canMessage = run.capabilities.message && !terminal;
if (!canCancel && !canPause && !canResume && !canMessage && !error) return null;
return (
<div className="space-y-2 pt-2 border-t border-border/30" data-testid={`run-controls-${run.id}`}>
<div className="flex flex-wrap gap-1.5">
{canPause && (
<button
type="button"
aria-label={`Pause ${run.title}`}
disabled={busy}
onClick={() => onControl('pause')}
className="inline-flex items-center gap-1 rounded border border-border/50 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<Pause className="w-3 h-3" /> Pause
</button>
)}
{canResume && (
<button
type="button"
aria-label={`Resume ${run.title}`}
disabled={busy}
onClick={() => onControl('resume')}
className="inline-flex items-center gap-1 rounded border border-border/50 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<Play className="w-3 h-3" /> Resume
</button>
)}
{canCancel && (
<button
type="button"
aria-label={`Cancel ${run.title}`}
disabled={busy}
onClick={() => onControl('cancel')}
className="inline-flex items-center gap-1 rounded border border-border/50 px-2 py-1 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Square className="w-3 h-3" /> Cancel
</button>
)}
</div>
{canMessage && (
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
const trimmed = message.trim();
if (trimmed && !busy) onControl('message', trimmed);
}}
>
<input
aria-label={`Message ${run.title}`}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
placeholder="Send guidance to this run…"
className="min-w-0 flex-1 rounded border border-border/50 bg-background px-2 py-1 text-[11px] text-foreground"
/>
<button
type="submit"
aria-label={`Send message to ${run.title}`}
disabled={busy || !message.trim()}
className="inline-flex items-center gap-1 rounded bg-primary/15 px-2 py-1 text-[11px] text-honey disabled:opacity-50"
>
<Send className="w-3 h-3" /> Send
</button>
</form>
)}
{error && <p role="alert" className="text-[11px] text-destructive">{error}</p>}
</div>
);
}
function RunData({ run }: { run: CollaborationRun }) {
const progress = run.progress;
const result = run.result;
const metrics = run.metrics;
const personalCount = run.memoryRefs.personalFrameIds.length;
const workspaceCount = Object.values(run.memoryRefs.workspaceFrameIds)
.reduce((count, ids) => count + ids.length, 0);
const progressPct = progress?.current !== undefined && progress.total
? Math.min(100, Math.max(0, (progress.current / progress.total) * 100))
: undefined;
return (
<div className="space-y-2">
{progress && (
<div className="space-y-1" data-testid={`run-progress-${run.id}`}>
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>{progress.phase ? `${progress.phase} · ` : ''}{progress.message}</span>
{progress.current !== undefined && progress.total !== undefined && (
<span>{progress.current}/{progress.total}</span>
)}
</div>
{progressPct !== undefined && (
<div
role="progressbar"
aria-label={`${run.title} progress`}
aria-valuemin={0}
aria-valuemax={progress.total}
aria-valuenow={progress.current}
className="h-1 overflow-hidden rounded-full bg-secondary"
>
<div className="h-full bg-primary" style={{ width: `${progressPct}%` }} />
</div>
)}
</div>
)}
{result?.summary && (
<p className="text-[11px] leading-relaxed text-foreground whitespace-pre-wrap" data-testid={`run-summary-${run.id}`}>
{result.summary}
</p>
)}
{result?.error && (
<p role="alert" className="text-[11px] text-destructive whitespace-pre-wrap">{result.error}</p>
)}
{result?.artifacts && result.artifacts.length > 0 && (
<div className="text-[11px] text-muted-foreground">
<p className="flex items-center gap-1 font-medium text-foreground"><FileText className="w-3 h-3" /> Artifacts</p>
<ul className="mt-1 space-y-0.5 font-mono">
{result.artifacts.map((artifact) => <li key={artifact} className="break-all">{artifact}</li>)}
</ul>
</div>
)}
{(result?.sessionId || result?.traceId || result?.exitCode !== undefined) && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-mono text-muted-foreground">
{result.sessionId && <span>Session · {result.sessionId}</span>}
{result.traceId && <span>Trace · {result.traceId}</span>}
{result.exitCode !== undefined && <span>Exit · {result.exitCode ?? 'pending'}</span>}
</div>
)}
{metrics && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted-foreground" data-testid={`run-metrics-${run.id}`}>
{(metrics.toolsUsed?.length ?? 0) > 0 && <span>Tools · {metrics.toolsUsed!.join(', ')}</span>}
{metrics.inputTokens !== undefined && <span>Input · {metrics.inputTokens.toLocaleString()} tokens</span>}
{metrics.outputTokens !== undefined && <span>Output · {metrics.outputTokens.toLocaleString()} tokens</span>}
{metrics.costUsd !== undefined && <span>Cost · ${metrics.costUsd.toFixed(4)}</span>}
</div>
)}
<div
className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md bg-secondary/30 px-2 py-1.5 text-[10px] text-muted-foreground"
data-testid={`run-memory-${run.id}`}
>
<span className="flex items-center gap-1 text-foreground"><Brain className="w-3 h-3" /> Memory · {run.memoryRefs.status}</span>
<span>Personal mind · {personalCount} frame{personalCount === 1 ? '' : 's'}</span>
<span>Workspace mind · {workspaceCount} frame{workspaceCount === 1 ? '' : 's'}</span>
</div>
</div>
);
}
interface CanonicalRunCardProps {
run: CollaborationWorkerRun;
workspaceName: string;
pending: ControlState | null;
controlError?: string;
message: string;
onMessageChange: (message: string) => void;
onControl: (action: CollaborationRunControl, message?: string) => void;
}
function CanonicalWorkerCard({
run, workspaceName, pending, controlError, message, onMessageChange, onControl,
}: CanonicalRunCardProps) {
const executor = run.executor.toolId ?? run.executor.personaId ?? run.executor.agentId ?? run.source;
const startedAt = run.startedAt ? Date.parse(run.startedAt) : undefined;
const completedAt = run.completedAt ? Date.parse(run.completedAt) : undefined;
return (
<article
className="min-w-0 space-y-2 rounded-xl border border-border/40 bg-card/50 p-3"
data-testid="canonical-worker-card"
data-run-id={run.id}
data-run-status={run.status}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<h5 className="text-xs font-display font-semibold text-foreground">{run.title}</h5>
<span className="rounded px-1.5 py-0.5 text-[10px]" style={runStatusStyle(run.status)}>
{runStatusLabel(run.status)}
</span>
</div>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{executor}{run.executor.model ? ` · ${run.executor.model}` : ''} · {workspaceName}
</p>
</div>
{startedAt && (
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="w-2.5 h-2.5" /> {formatElapsed(startedAt, completedAt)}
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground">{run.task}</p>
<RunData run={run} />
<RunControls
run={run}
pending={pending}
error={controlError}
message={message}
onMessageChange={onMessageChange}
onControl={onControl}
/>
</article>
);
}
interface RoomSummaryProps {
room: CollaborationRoomRun;
workers: CollaborationWorkerRun[];
workspaceNames: Record<string, string>;
pending: ControlState | null;
controlError?: string;
message: string;
onMessageChange: (message: string) => void;
onControl: (action: CollaborationRunControl, message?: string) => void;
}
function RoomSummary({
room, workers, workspaceNames, pending, controlError, message, onMessageChange, onControl,
}: RoomSummaryProps) {
const settled = workers.filter((worker) => isTerminalRun(worker.status)).length;
const statusCounts = new Map<CollaborationRunStatus, number>();
for (const worker of workers) statusCounts.set(worker.status, (statusCounts.get(worker.status) ?? 0) + 1);
return (
<section className="space-y-3 rounded-xl border border-primary/30 bg-primary/5 p-4" data-testid="room-root-summary">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-display font-semibold text-foreground">{room.title}</h4>
<span className="rounded px-1.5 py-0.5 text-[10px]" style={runStatusStyle(room.status)}>
{runStatusLabel(room.status)}
</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{room.task}</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{settled}/{workers.length} settled</span>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
<span>Source · {room.source}</span>
<span>Workspaces · {room.workspaceIds.map((id) => workspaceNames[id] ?? id).join(', ')}</span>
{[...statusCounts].map(([status, count]) => (
<span key={status}>{runStatusLabel(status)} · {count}</span>
))}
</div>
<RunData run={room} />
<RunControls
run={room}
pending={pending}
error={controlError}
message={message}
onMessageChange={onMessageChange}
onControl={onControl}
/>
</section>
);
}
/** One participant row in the "In the room" panel. */
interface Participant {
key: string;
name: string;
role: string;
live: boolean;
}
const RoomApp = ({ workspaceId, roomId, workspaceNames = {} }: RoomAppProps) => {
const {
workspaceMap, totalLive, connecting, error, reconnect,
focusedRoom, focusedWorkers = [],
} = useRoomState(roomId);
const [showRecent, setShowRecent] = useState(false);
const [pendingControl, setPendingControl] = useState<ControlState | null>(null);
const [controlErrors, setControlErrors] = useState<Record<string, string>>({});
const [messageDrafts, setMessageDrafts] = useState<Record<string, string>>({});
const handleControl = async (
runId: string,
action: CollaborationRunControl,
message?: string,
) => {
setPendingControl({ runId, action });
setControlErrors((current) => {
const next = { ...current };
delete next[runId];
return next;
});
try {
await adapter.controlAgentRun(runId, action, message);
if (action === 'message') {
setMessageDrafts((current) => ({ ...current, [runId]: '' }));
}
} catch (err) {
setControlErrors((current) => ({
...current,
[runId]: err instanceof Error ? err.message : 'Run control failed',
}));
} finally {
setPendingControl(null);
}
};
// Flatten all workspaces (or just the filtered one) into a live list + recent list.
const { liveAgents, recentAgents } = useMemo(() => {
const live: Array<{ agent: RoomAgent; workspaceId: string }> = [];
const recent: Array<{ agent: RoomAgent; workspaceId: string }> = [];
const entries = workspaceId
? (workspaceMap.has(workspaceId) ? [[workspaceId, workspaceMap.get(workspaceId)!] as const] : [])
: [...workspaceMap.entries()];
for (const [wsId, data] of entries) {
for (const a of data.live) live.push({ agent: a, workspaceId: wsId });
for (const a of data.recent) recent.push({ agent: a, workspaceId: wsId });
}
return { liveAgents: live, recentAgents: recent };
}, [workspaceMap, workspaceId]);
const liveCount = liveAgents.length;
const recentCount = recentAgents.length;
// Participants panel — derived from the SAME real SSE roster, never invented.
// The host ("You") is always present; live agents show first, then recently-
// finished ones (deduped by id so an agent isn't listed twice). D20: no
// fabricated invitees — only agents the live feed actually reports.
const participants = useMemo<Participant[]>(() => {
if (focusedRoom) {
return focusedWorkers.map((run) => ({
key: run.id,
name: run.title,
role: run.executor.toolId ?? run.executor.personaId ?? run.executor.agentId ?? run.source,
live: !isTerminalRun(run.status),
}));
}
const seen = new Set<string>();
const rows: Participant[] = [];
for (const { agent } of liveAgents) {
if (seen.has(agent.id)) continue;
seen.add(agent.id);
rows.push({ key: agent.id, name: agent.name, role: agent.role, live: true });
}
for (const { agent } of recentAgents) {
if (seen.has(agent.id)) continue;
seen.add(agent.id);
rows.push({ key: agent.id, name: agent.name, role: agent.role, live: false });
}
return rows;
}, [focusedRoom, focusedWorkers, liveAgents, recentAgents]);
const focusedLiveCount = focusedWorkers.filter((run) => !isTerminalRun(run.status)).length;
const focusedRecentCount = focusedWorkers.length - focusedLiveCount;
const displayedLiveCount = focusedRoom ? focusedLiveCount : liveCount;
const displayedRecentCount = focusedRoom ? focusedRecentCount : recentCount;
const hasRoster = Boolean(focusedRoom) || liveCount > 0 || recentCount > 0;
return (
<div className="h-full flex flex-col overflow-hidden bg-background" data-testid="room-root">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/30 shrink-0">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-honey" />
<h3 className="text-sm font-display font-semibold text-foreground">Room</h3>
<span className="text-[11px] text-muted-foreground" data-testid="room-live-count">
{displayedLiveCount} live
{displayedRecentCount > 0 && (
<> · {displayedRecentCount} {focusedRoom ? 'settled' : 'recent'}</>
)}
{!focusedRoom && totalLive === 0 && recentCount === 0 && <> · no agents running</>}
</span>
{focusedRoom && (
<span className="rounded px-1.5 py-0.5 text-[10px]" style={runStatusStyle(focusedRoom.status)}>
{runStatusLabel(focusedRoom.status)}
</span>
)}
</div>
{workspaceId && (
<span className="text-[11px] text-muted-foreground">
filtered to {workspaceNames[workspaceId] ?? workspaceId}
</span>
)}
</div>
{/* Content — full-bleed states (error / connecting / empty) take the
whole canvas; once there's a roster we split into the two-column
stage + participants layout. */}
{/* P7/D15 B2: a broken SSE channel is NOT an idle room. Error and
connecting take precedence over the "no agents running" empty state. */}
{error ? (
<div role="alert" className="flex-1 flex flex-col items-center justify-center text-center p-4">
<AlertCircle className="w-10 h-10 text-destructive/60 mb-3" />
<p className="text-sm font-display text-foreground">Room channel disconnected</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-xs">
Lost the live agent feed this is a connection error, not an empty room. {error}
</p>
<button
onClick={reconnect}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/15 text-honey text-[11px] font-display hover:bg-primary/25 transition-colors"
>
<Loader2 className="w-3.5 h-3.5" /> Reconnect
</button>
</div>
) : connecting ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-4" data-testid="room-connecting">
<Loader2 className="w-10 h-10 text-muted-foreground/30 mb-3 animate-spin" />
<p className="text-sm font-display text-foreground">Connecting to the Room</p>
</div>
) : roomId && !focusedRoom ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-4" data-testid="room-not-found">
<AlertCircle className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm font-display text-foreground">Room not found</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-sm">
No durable Room exists for <span className="font-mono">{roomId}</span>. Check the link or open the Room overview.
</p>
</div>
) : !hasRoster ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-4">
<Users className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm font-display text-foreground">No agents running</p>
<p className="text-[11px] text-muted-foreground mt-1 max-w-xs">
Spawn a Waggle agent, run an agent group, or launch a captured external task to watch it work here.
</p>
</div>
) : (
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-4 p-4 overflow-hidden">
{/* Turn stage — the live + recently-completed agents working */}
<div className="min-h-0 overflow-auto space-y-4" data-testid="room-stage">
{focusedRoom && (
<>
<RoomSummary
room={focusedRoom}
workers={focusedWorkers}
workspaceNames={workspaceNames}
pending={pendingControl}
controlError={controlErrors[focusedRoom.id]}
message={messageDrafts[focusedRoom.id] ?? ''}
onMessageChange={(message) => setMessageDrafts((current) => ({
...current, [focusedRoom.id]: message,
}))}
onControl={(action, message) => void handleControl(focusedRoom.id, action, message)}
/>
<section className="space-y-2">
<h4 className="text-[11px] font-display font-semibold uppercase tracking-wider text-muted-foreground">
Agents ({focusedWorkers.length})
</h4>
{focusedWorkers.length > 0 ? (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-2.5">
{focusedWorkers.map((run) => (
<CanonicalWorkerCard
key={run.id}
run={run}
workspaceName={workspaceNames[run.workspaceId] ?? run.workspaceId}
pending={pendingControl}
controlError={controlErrors[run.id]}
message={messageDrafts[run.id] ?? ''}
onMessageChange={(message) => setMessageDrafts((current) => ({
...current, [run.id]: message,
}))}
onControl={(action, message) => void handleControl(run.id, action, message)}
/>
))}
</div>
) : (
<p className="rounded-lg border border-border/30 bg-card/30 p-3 text-[11px] text-muted-foreground">
This Room has no registered worker runs yet.
</p>
)}
</section>
</>
)}
{!focusedRoom && liveCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: 'var(--honey)' }} />
<p className="text-[11px] font-display font-semibold uppercase tracking-wider text-muted-foreground">
Live ({liveCount})
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{liveAgents.map(({ agent, workspaceId: wsId }) => (
<AgentTile
key={`${wsId}-${agent.id}`}
agent={agent}
workspaceName={workspaceNames[wsId]}
/>
))}
</div>
</div>
)}
{!focusedRoom && recentCount > 0 && (
<div>
<button
onClick={() => setShowRecent(v => !v)}
className="flex items-center gap-2 mb-2 text-[11px] font-display font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<CheckCircle2 className="w-3 h-3" />
Recently completed ({recentCount}) {showRecent ? '▼' : '▶'}
</button>
{showRecent && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5 opacity-80">
{recentAgents.map(({ agent, workspaceId: wsId }) => (
<AgentTile
key={`recent-${wsId}-${agent.id}`}
agent={agent}
workspaceName={workspaceNames[wsId]}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Participants panel — "In the room" */}
<aside
className="min-h-0 overflow-auto rounded-xl border border-border/40 bg-card/40 p-4"
aria-label="Participants in the room"
data-testid="room-participants"
>
<h4 className="text-[10.5px] font-display font-semibold uppercase tracking-[0.1em] text-muted-foreground mb-3">
In the room
</h4>
<ul className="space-y-0.5">
<li className="flex items-center gap-2.5 py-1.5 text-[13px]">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: 'var(--honey)' }} />
<span className="flex-1 text-foreground truncate">You</span>
<span className="text-[11px] text-muted-foreground">host</span>
</li>
{participants.map((p) => (
<li key={p.key} className="flex items-center gap-2.5 py-1.5 text-[13px]" data-testid="room-participant">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${p.live ? 'animate-pulse' : ''}`}
style={{ background: p.live ? 'var(--healthy)' : 'var(--text-dim)' }}
/>
<span className="flex-1 text-foreground truncate">{p.name}</span>
<span className="text-[11px] text-muted-foreground">{p.live ? 'live' : p.role}</span>
</li>
))}
</ul>
<p className="mt-4 text-[12px] text-muted-foreground leading-relaxed">
{focusedRoom
? 'Each agent works in its bound workspace. Full results return to that workspace mind, with a concise reference in your personal mind.'
: 'Waggle agents, groups, and captured external tasks appear here across your workspaces.'}
</p>
</aside>
</div>
)}
</div>
);
};
export default RoomApp;

File diff suppressed because it is too large Load Diff

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