From 7cbf75126217e7687cfee079dc09db633d5ba34b Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 27 May 2026 11:56:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Stage=201=20demo=20mode=20=E2=80=94?= =?UTF-8?q?=20UI=20runs=20with=20no=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSW v2 intercepts /api/* fetch + SSE so the UI bootstraps cleanly with no Alice/UTA running. Auto-authed, PTY terminal stub'd, minimum-viable fixtures for the 5 non-trivial domains (auth, trading, workspaces, events SSE, chat SSE); the long tail of ~130 endpoints falls through a catchAll handler that returns 200 {} and console.warns the unmocked path so Stage 2 can walk the log and flesh out per-endpoint handlers. Colocated under ui/src/demo/ behind import.meta.env.VITE_DEMO_MODE — Rollup tree-shakes the entire subtree (and the Terminal.tsx stub branch) out of production bundles. Verified: prod bundle grep for msw/VITE_DEMO_MODE/ DemoTerminalStub returns zero matches. Files of note: - ui/src/demo/handlers/catchAll.ts uses a RegExp literal (not '/api/*' glob) to dodge a path-to-regexp v8 incompatibility inside MSW v2's string-pattern matcher - Root pnpm.overrides adds scoped 'msw>path-to-regexp@^6.3.0' so MSW gets the v6-syntax-compatible (and CVE-patched) version, while everyone else stays on the security-pinned v8 override - ui/src/components/workspace/Terminal.tsx gets a one-line early-return guard that renders DemoTerminalStub when demo mode is on — verified zero WebSocket attempts to /api/workspaces/pty when a session pane opens Verified end-to-end in browser (Playwright): - npx tsc -b in ui/ clean - pnpm -F open-alice-ui dev:demo + navigate to /inbox, /portfolio, /news, /workspaces/demo-ws, /workspaces/demo-ws/s/demo-session, /settings: 0 console errors throughout, demo terminal stub renders correctly - 2 catchAll warns surfaced (PUT /api/config/snapshot, PUT /api/config/compaction) — Stage 2 backlog - pnpm -F open-alice-ui build (no demo flag): 2.8MB main chunk, zero msw references, zero VITE_DEMO_MODE references, zero DemoTerminalStub Out of scope (Stage 2): rich fixtures, scripted SSE timelines, PTY replay, in-memory mutation persistence, demo-mode banner. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + package.json | 1 + pnpm-lock.yaml | 319 +++++++++++++++++++- ui/.env.demo | 1 + ui/package.json | 13 +- ui/public/mockServiceWorker.js | 349 ++++++++++++++++++++++ ui/src/components/workspace/Terminal.tsx | 2 + ui/src/demo/DemoTerminalStub.tsx | 23 ++ ui/src/demo/README.md | 37 +++ ui/src/demo/fixtures/auth.ts | 8 + ui/src/demo/fixtures/events.ts | 8 + ui/src/demo/fixtures/index.ts | 4 + ui/src/demo/fixtures/trading.ts | 48 +++ ui/src/demo/fixtures/workspaces.ts | 41 +++ ui/src/demo/handlers/agentStatus.ts | 8 + ui/src/demo/handlers/auth.ts | 8 + ui/src/demo/handlers/catchAll.ts | 12 + ui/src/demo/handlers/channels.ts | 12 + ui/src/demo/handlers/chat.ts | 48 +++ ui/src/demo/handlers/configKeys.ts | 39 +++ ui/src/demo/handlers/cron.ts | 9 + ui/src/demo/handlers/devMisc.ts | 27 ++ ui/src/demo/handlers/events.ts | 35 +++ ui/src/demo/handlers/inbox.ts | 9 + ui/src/demo/handlers/index.ts | 38 +++ ui/src/demo/handlers/market.ts | 31 ++ ui/src/demo/handlers/notificationsNews.ts | 6 + ui/src/demo/handlers/personaHeartbeat.ts | 16 + ui/src/demo/handlers/toolsSimulator.ts | 24 ++ ui/src/demo/handlers/trading.ts | 103 +++++++ ui/src/demo/handlers/workspaces.ts | 57 ++++ ui/src/demo/index.ts | 8 + ui/src/demo/worker.ts | 4 + ui/src/main.tsx | 4 + ui/src/vite-env.d.ts | 9 + 35 files changed, 1349 insertions(+), 13 deletions(-) create mode 100644 ui/.env.demo create mode 100644 ui/public/mockServiceWorker.js create mode 100644 ui/src/demo/DemoTerminalStub.tsx create mode 100644 ui/src/demo/README.md create mode 100644 ui/src/demo/fixtures/auth.ts create mode 100644 ui/src/demo/fixtures/events.ts create mode 100644 ui/src/demo/fixtures/index.ts create mode 100644 ui/src/demo/fixtures/trading.ts create mode 100644 ui/src/demo/fixtures/workspaces.ts create mode 100644 ui/src/demo/handlers/agentStatus.ts create mode 100644 ui/src/demo/handlers/auth.ts create mode 100644 ui/src/demo/handlers/catchAll.ts create mode 100644 ui/src/demo/handlers/channels.ts create mode 100644 ui/src/demo/handlers/chat.ts create mode 100644 ui/src/demo/handlers/configKeys.ts create mode 100644 ui/src/demo/handlers/cron.ts create mode 100644 ui/src/demo/handlers/devMisc.ts create mode 100644 ui/src/demo/handlers/events.ts create mode 100644 ui/src/demo/handlers/inbox.ts create mode 100644 ui/src/demo/handlers/index.ts create mode 100644 ui/src/demo/handlers/market.ts create mode 100644 ui/src/demo/handlers/notificationsNews.ts create mode 100644 ui/src/demo/handlers/personaHeartbeat.ts create mode 100644 ui/src/demo/handlers/toolsSimulator.ts create mode 100644 ui/src/demo/handlers/trading.ts create mode 100644 ui/src/demo/handlers/workspaces.ts create mode 100644 ui/src/demo/index.ts create mode 100644 ui/src/demo/worker.ts create mode 100644 ui/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index fb81e0492..4c2505237 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ state.json .env .env.* +!ui/.env.demo # OS .DS_Store diff --git a/package.json b/package.json index b90d639dc..c34d70d40 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@alpacahq/alpaca-trade-api>eslint": "-", "lodash": "^4.18.0", "path-to-regexp": "^8.4.0", + "msw>path-to-regexp": "^6.3.0", "follow-redirects": "^1.16.0", "express-rate-limit": "^8.2.2", "picomatch": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd98c60e1..c7a6bc71c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@alpacahq/alpaca-trade-api>eslint': '-' lodash: ^4.18.0 path-to-regexp: ^8.4.0 + msw>path-to-regexp: ^6.3.0 follow-redirects: ^1.16.0 express-rate-limit: ^8.2.2 picomatch: ^4.0.4 @@ -140,7 +141,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) apps/desktop: dependencies: @@ -177,7 +178,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.6 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(tsx@4.21.0) packages/opentypebb: dependencies: @@ -211,7 +212,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.6 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(tsx@4.21.0) packages/uta-protocol: dependencies: @@ -233,7 +234,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.6 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(tsx@4.21.0) services/uta: dependencies: @@ -344,6 +345,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.7.0(vite@6.4.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + msw: + specifier: ^2.14.6 + version: 2.14.6(@types/node@25.2.3)(typescript@5.9.3) tailwindcss: specifier: ^4.1.8 version: 4.2.1 @@ -1045,6 +1049,41 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.6': + resolution: {integrity: sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/confirm@6.1.0': + resolution: {integrity: sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.0': + resolution: {integrity: sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.6': + resolution: {integrity: sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/type@4.0.6': + resolution: {integrity: sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1083,6 +1122,10 @@ packages: '@cfworker/json-schema': optional: true + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -1104,6 +1147,18 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1580,6 +1635,12 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2027,6 +2088,10 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2501,9 +2566,18 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -2665,6 +2739,10 @@ packages: resolution: {integrity: sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==} engines: {node: ^12.20.0 || >=14.13.1} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2687,6 +2765,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -2811,6 +2892,9 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -3349,6 +3433,20 @@ packages: msgpack5@5.3.2: resolution: {integrity: sha512-e9jz+6InQIUb2cGzZ/Mi+rQBs1KFLby+gNi+22VwQ1pnC9ieZjysKGmRMjdlf6IyjsltbgY4tGoHwHmyS7l94A==} + msw@2.14.6: + resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@4.0.0: + resolution: {integrity: sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3477,6 +3575,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + ox@0.14.20: resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} peerDependencies: @@ -3519,6 +3620,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -3787,6 +3891,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -3862,6 +3969,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3968,6 +4078,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4013,6 +4126,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@4.2.1: resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} @@ -4193,6 +4310,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -4239,6 +4360,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5187,6 +5311,59 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@inquirer/ansi@2.0.6': {} + + '@inquirer/confirm@6.1.0(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 11.2.0(@types/node@22.19.15) + '@inquirer/type': 4.0.6(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + optional: true + + '@inquirer/confirm@6.1.0(@types/node@25.2.3)': + dependencies: + '@inquirer/core': 11.2.0(@types/node@25.2.3) + '@inquirer/type': 4.0.6(@types/node@25.2.3) + optionalDependencies: + '@types/node': 25.2.3 + + '@inquirer/core@11.2.0(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 2.0.6 + '@inquirer/figures': 2.0.6 + '@inquirer/type': 4.0.6(@types/node@22.19.15) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 4.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.15 + optional: true + + '@inquirer/core@11.2.0(@types/node@25.2.3)': + dependencies: + '@inquirer/ansi': 2.0.6 + '@inquirer/figures': 2.0.6 + '@inquirer/type': 4.0.6(@types/node@25.2.3) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 4.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.2.3 + + '@inquirer/figures@2.0.6': {} + + '@inquirer/type@4.0.6(@types/node@22.19.15)': + optionalDependencies: + '@types/node': 22.19.15 + optional: true + + '@inquirer/type@4.0.6(@types/node@25.2.3)': + optionalDependencies: + '@types/node': 25.2.3 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5250,6 +5427,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': @@ -5268,6 +5454,17 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@opentelemetry/api@1.9.0': {} '@pinojs/redact@0.4.0': {} @@ -5647,6 +5844,12 @@ snapshots: dependencies: '@types/node': 25.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.2.3 + + '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': optional: true @@ -5690,7 +5893,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@vitest/expect@3.2.4': dependencies: @@ -5709,20 +5912,22 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.14.6(@types/node@22.19.15)(typescript@5.9.3) vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - '@vitest/mocker@4.1.5(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.5(msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.14.6(@types/node@25.2.3)(typescript@5.9.3) vite: 7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': @@ -6223,6 +6428,8 @@ snapshots: string-width: 4.2.3 optional: true + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -6758,8 +6965,18 @@ snapshots: fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -6977,6 +7194,8 @@ snapshots: - encoding - supports-color + graphql@16.14.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -6996,6 +7215,11 @@ snapshots: dependencies: function-bind: 1.1.2 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 + highlight.js@11.11.1: {} hono@4.12.14: {} @@ -7115,6 +7339,8 @@ snapshots: is-lambda@1.0.1: {} + is-node-process@1.2.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -7569,6 +7795,59 @@ snapshots: readable-stream: 3.6.2 safe-buffer: 5.2.1 + msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.0(@types/node@22.19.15) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + + msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.0(@types/node@25.2.3) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@4.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -7687,6 +7966,8 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + outvariant@1.4.3: {} + ox@0.14.20(typescript@5.9.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -7729,6 +8010,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -7988,6 +8271,8 @@ snapshots: retry@0.12.0: {} + rettime@0.11.11: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -8104,6 +8389,8 @@ snapshots: set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -8211,6 +8498,8 @@ snapshots: std-env@4.1.0: {} + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8269,6 +8558,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -8460,6 +8751,10 @@ snapshots: type-fest@0.13.1: optional: true + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -8492,6 +8787,8 @@ snapshots: unpipe@1.0.0: {} + until-async@3.0.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -8643,11 +8940,11 @@ snapshots: lightningcss: 1.32.0 tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(msw@2.14.6(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8686,10 +8983,10 @@ snapshots: - tsx - yaml - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.5(msw@2.14.6(@types/node@25.2.3)(typescript@5.9.3))(vite@7.3.2(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 diff --git a/ui/.env.demo b/ui/.env.demo new file mode 100644 index 000000000..cb4ba7820 --- /dev/null +++ b/ui/.env.demo @@ -0,0 +1 @@ +VITE_DEMO_MODE=true diff --git a/ui/package.json b/ui/package.json index 446b8a550..85e71a1ff 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,8 +4,11 @@ "type": "module", "scripts": { "dev": "vite", + "dev:demo": "vite --mode demo", "build": "tsc -b && vite build", - "preview": "vite preview" + "build:demo": "tsc -b && vite build --mode demo", + "preview": "vite preview", + "msw:init": "msw init ./public --save" }, "dependencies": { "@xterm/addon-fit": "^0.10.0", @@ -31,8 +34,14 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", + "msw": "^2.14.6", "tailwindcss": "^4.1.8", "typescript": "^5.9.3", "vite": "^6.3.5" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/ui/public/mockServiceWorker.js b/ui/public/mockServiceWorker.js new file mode 100644 index 000000000..33dde9e77 --- /dev/null +++ b/ui/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/ui/src/components/workspace/Terminal.tsx b/ui/src/components/workspace/Terminal.tsx index 93f7dc12f..b7c3fdcec 100644 --- a/ui/src/components/workspace/Terminal.tsx +++ b/ui/src/components/workspace/Terminal.tsx @@ -12,6 +12,7 @@ import { type ClientControlMessage, } from './protocol'; import { darkTheme } from './theme'; +import { DemoTerminalStub } from '../../demo/DemoTerminalStub'; type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'kicked'; @@ -66,6 +67,7 @@ export interface TerminalViewProps { } export function TerminalView(props: TerminalViewProps): ReactElement { + if (import.meta.env.VITE_DEMO_MODE) return ; const containerRef = useRef(null); const [status, setStatus] = useState('connecting'); const [pid, setPid] = useState(null); diff --git a/ui/src/demo/DemoTerminalStub.tsx b/ui/src/demo/DemoTerminalStub.tsx new file mode 100644 index 000000000..ac42049ac --- /dev/null +++ b/ui/src/demo/DemoTerminalStub.tsx @@ -0,0 +1,23 @@ +import type { ReactElement } from 'react' + +interface DemoTerminalStubProps { + readonly label: string +} + +export function DemoTerminalStub({ label }: DemoTerminalStubProps): ReactElement { + return ( +
+
+
+ {label} +
+
+ Demo mode — terminal disabled. +
+
+ The PTY backend isn't wired up in demo mode. Run a real Alice instance to use this terminal. +
+
+
+ ) +} diff --git a/ui/src/demo/README.md b/ui/src/demo/README.md new file mode 100644 index 000000000..744ee901b --- /dev/null +++ b/ui/src/demo/README.md @@ -0,0 +1,37 @@ +# `ui/src/demo/` — Stage 1 Demo Mode + +Runs the UI with no backend by intercepting `fetch` / `EventSource` via MSW. +Gated by `import.meta.env.VITE_DEMO_MODE`; tree-shaken in production builds. + +## How + +`ui/src/main.tsx` conditionally `await import('./demo')` before `createRoot`. +`./index.ts` starts the MSW service worker registered against +`ui/public/mockServiceWorker.js` (generated by `pnpm -F open-alice-ui msw:init`). + +## Layout + +- `handlers/` — one file per `ui/src/api/*.ts` domain. `catchAll.ts` MUST stay + last in `handlers/index.ts`. +- `fixtures/` — typed const exports. Only non-trivial shapes get their own + file; empty arrays / nulls live inline in handlers. +- `DemoTerminalStub.tsx` — rendered by `TerminalView` when the env flag is on + (Terminal.tsx has a 1-line early-return guard). + +## Running + +```bash +pnpm -F open-alice-ui dev:demo # vite dev server, demo mode +pnpm -F open-alice-ui build:demo # static build +``` + +## Stage 2 backlog + +Walk the `[demo] unmocked …` console.warn output from `catchAll` to find +endpoints that need real handlers. Then: + +- Rich fixtures (multiple UTAs, varied positions, P&L, workspace sessions). +- Scripted SSE timelines for events + chat (multi-turn convos, tool calls). +- WebSocket PTY replay — waits for MSW v2 WS GA (currently `next` tag). +- In-memory mutation persistence (create-then-reload survives). +- Persistent "Demo Mode" UI banner. diff --git a/ui/src/demo/fixtures/auth.ts b/ui/src/demo/fixtures/auth.ts new file mode 100644 index 000000000..d4bcc4a39 --- /dev/null +++ b/ui/src/demo/fixtures/auth.ts @@ -0,0 +1,8 @@ +export const demoAuthStatus = { + authed: true, + tokenConfigured: true, + session: { + createdAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + }, +} diff --git a/ui/src/demo/fixtures/events.ts b/ui/src/demo/fixtures/events.ts new file mode 100644 index 000000000..d8319d5e8 --- /dev/null +++ b/ui/src/demo/fixtures/events.ts @@ -0,0 +1,8 @@ +import type { EventLogEntry } from '../../api/types' + +export const demoEvent: EventLogEntry = { + seq: 1, + ts: Date.now(), + type: 'demo.welcome', + payload: { message: 'Demo mode active — events stream is idle after this entry.' }, +} diff --git a/ui/src/demo/fixtures/index.ts b/ui/src/demo/fixtures/index.ts new file mode 100644 index 000000000..9fcbb6902 --- /dev/null +++ b/ui/src/demo/fixtures/index.ts @@ -0,0 +1,4 @@ +export * from './auth' +export * from './trading' +export * from './workspaces' +export * from './events' diff --git a/ui/src/demo/fixtures/trading.ts b/ui/src/demo/fixtures/trading.ts new file mode 100644 index 000000000..ae25c36b3 --- /dev/null +++ b/ui/src/demo/fixtures/trading.ts @@ -0,0 +1,48 @@ +import type { + TradingAccount, + UTASummary, + AccountInfo, + UTAConfig, +} from '../../api/types' + +export const DEMO_UTA_ID = 'demo-uta' + +export const demoTradingAccount: TradingAccount = { + id: DEMO_UTA_ID, + provider: 'mock', + label: 'Demo Paper Account', +} + +export const demoUTASummary: UTASummary = { + id: DEMO_UTA_ID, + label: 'Demo Paper Account', + capabilities: { + supportedSecTypes: ['STK', 'CRYPTO'], + supportedOrderTypes: ['MKT', 'LMT'], + }, + health: { + status: 'healthy', + consecutiveFailures: 0, + lastSuccessAt: new Date().toISOString(), + recovering: false, + disabled: false, + }, +} + +export const demoAccountInfo: AccountInfo = { + baseCurrency: 'USD', + netLiquidation: '10000.00', + totalCashValue: '10000.00', + unrealizedPnL: '0.00', + realizedPnL: '0.00', + buyingPower: '10000.00', +} + +export const demoUTAConfig: UTAConfig = { + id: DEMO_UTA_ID, + label: 'Demo Paper Account', + presetId: 'mock', + enabled: true, + guards: [], + presetConfig: {}, +} diff --git a/ui/src/demo/fixtures/workspaces.ts b/ui/src/demo/fixtures/workspaces.ts new file mode 100644 index 000000000..b2c9356d5 --- /dev/null +++ b/ui/src/demo/fixtures/workspaces.ts @@ -0,0 +1,41 @@ +import type { Workspace, TemplateInfo, SessionRecord } from '../../components/workspace/api' + +export const DEMO_WORKSPACE_ID = 'demo-ws' +export const DEMO_SESSION_ID = 'demo-session' + +const demoSession: SessionRecord = { + id: DEMO_SESSION_ID, + wsId: DEMO_WORKSPACE_ID, + agent: 'claude', + name: 'c1', + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + state: 'running', + agentSessionId: null, + pid: 0, + startedAt: Date.now(), +} + +export const demoWorkspace: Workspace = { + id: DEMO_WORKSPACE_ID, + tag: 'demo', + dir: '/demo/workspaces/demo', + createdAt: new Date().toISOString(), + template: 'demo-template', + spawnedFromVersion: '0.1.0', + currentVersion: '0.1.0', + upgradeAvailable: null, + agents: ['claude'], + sessions: [demoSession], + agentOverride: { claude: false, codex: false }, +} + +export const demoTemplate: TemplateInfo = { + name: 'demo-template', + displayName: 'Demo Template', + description: 'A read-only demo template — workspace creation is disabled in demo mode.', + groupOrder: 0, + defaultAgents: ['claude'], + version: '0.1.0', + hasReadme: false, +} diff --git a/ui/src/demo/handlers/agentStatus.ts b/ui/src/demo/handlers/agentStatus.ts new file mode 100644 index 000000000..5057c1cb0 --- /dev/null +++ b/ui/src/demo/handlers/agentStatus.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from 'msw' + +export const agentStatusHandlers = [ + http.get('/api/agent-status', () => + HttpResponse.json({ entries: [], total: 0, page: 1, pageSize: 50, totalPages: 0 }), + ), + http.get('/api/agent-status/recent', () => HttpResponse.json({ entries: [], lastSeq: 0 })), +] diff --git a/ui/src/demo/handlers/auth.ts b/ui/src/demo/handlers/auth.ts new file mode 100644 index 000000000..21fc496f1 --- /dev/null +++ b/ui/src/demo/handlers/auth.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from 'msw' +import { demoAuthStatus } from '../fixtures/auth' + +export const authHandlers = [ + http.get('/api/auth/status', () => HttpResponse.json(demoAuthStatus)), + http.post('/api/auth/login', () => HttpResponse.json({ ok: true })), + http.post('/api/auth/logout', () => HttpResponse.json({ ok: true })), +] diff --git a/ui/src/demo/handlers/catchAll.ts b/ui/src/demo/handlers/catchAll.ts new file mode 100644 index 000000000..f6f140c8b --- /dev/null +++ b/ui/src/demo/handlers/catchAll.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse } from 'msw' + +// Last-resort 200 {} for any /api/* not explicitly mocked. RegExp literal is +// required: the glob form '/api/*' tickles a path-to-regexp v8 incompatibility +// inside MSW v2's matcher and breaks the whole handler chain. +export const catchAllHandlers = [ + http.all(/\/api\//, ({ request }) => { + const url = new URL(request.url) + console.warn('[demo] unmocked', request.method, url.pathname + url.search) + return HttpResponse.json({}) + }), +] diff --git a/ui/src/demo/handlers/channels.ts b/ui/src/demo/handlers/channels.ts new file mode 100644 index 000000000..c2847e437 --- /dev/null +++ b/ui/src/demo/handlers/channels.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse } from 'msw' + +export const channelsHandlers = [ + http.get('/api/channels', () => HttpResponse.json({ channels: [] })), + http.post('/api/channels', () => + HttpResponse.json({ channel: { id: 'demo-channel', label: 'Demo Channel' } }), + ), + http.put('/api/channels/:id', () => + HttpResponse.json({ channel: { id: 'demo-channel', label: 'Demo Channel' } }), + ), + http.delete('/api/channels/:id', () => new HttpResponse(null, { status: 204 })), +] diff --git a/ui/src/demo/handlers/chat.ts b/ui/src/demo/handlers/chat.ts new file mode 100644 index 000000000..407c6c618 --- /dev/null +++ b/ui/src/demo/handlers/chat.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from 'msw' + +// POST /api/chat returns SSE-shaped stream. Final event is `done`, after +// which we close the controller (UI's reader loop exits on `done`). +function chatPostStream(): Response { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + const text = 'Hello from demo mode — chat is a static stub here. Stage 2 will replace this with a scripted reply.' + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'stream', event: { type: 'text', text } })}\n\n`), + ) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'done', text, media: [] })}\n\n`), + ) + controller.close() + }, + }) + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} + +// GET /api/chat/events — legacy inbound notification channel. Stays open idle. +function chatEventsStream(): Response { + const stream = new ReadableStream({ + start() { + // no initial event; stay open + }, + }) + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} + +export const chatHandlers = [ + http.post('/api/chat', () => chatPostStream()), + http.get('/api/chat/events', () => chatEventsStream()), + http.get('/api/chat/history', () => HttpResponse.json({ messages: [], hasMore: false })), +] diff --git a/ui/src/demo/handlers/configKeys.ts b/ui/src/demo/handlers/configKeys.ts new file mode 100644 index 000000000..9aea1dafd --- /dev/null +++ b/ui/src/demo/handlers/configKeys.ts @@ -0,0 +1,39 @@ +import { http, HttpResponse } from 'msw' + +export const configKeysHandlers = [ + http.get('/api/config/api-keys/status', () => HttpResponse.json({})), + http.put('/api/config/apiKeys', () => new HttpResponse(null, { status: 204 })), + + http.get('/api/config', () => + HttpResponse.json({ + aiProvider: { apiKeys: {}, profiles: {}, activeProfile: '' }, + engine: {}, + agent: { evolutionMode: false, claudeCode: {} }, + compaction: { maxContextTokens: 0, maxOutputTokens: 0 }, + heartbeat: { enabled: false, every: '1h', prompt: '', activeHours: null }, + snapshot: { enabled: false, every: '1h' }, + mcp: { port: 47332 }, + connectors: { + web: { port: 47331 }, + mcpAsk: { enabled: false }, + telegram: { enabled: false, chatIds: [] }, + }, + }), + ), + + http.get('/api/config/profiles', () => + HttpResponse.json({ profiles: {}, credentials: {}, activeProfile: '' }), + ), + http.post('/api/config/profiles', () => + HttpResponse.json({ slug: 'demo', profile: { backend: 'mock', model: 'demo' } }, { status: 201 }), + ), + http.put('/api/config/profiles/:slug', () => + HttpResponse.json({ slug: 'demo', profile: { backend: 'mock', model: 'demo' } }), + ), + http.delete('/api/config/profiles/:slug', () => HttpResponse.json({ success: true })), + http.post('/api/config/profiles/test', () => HttpResponse.json({ ok: true })), + http.put('/api/config/active-profile', () => HttpResponse.json({ ok: true })), + + http.get('/api/config/presets', () => HttpResponse.json({ presets: [] })), + http.get('/api/config/sdk-adapters', () => HttpResponse.json({ adapters: [] })), +] diff --git a/ui/src/demo/handlers/cron.ts b/ui/src/demo/handlers/cron.ts new file mode 100644 index 000000000..2d5ed5da2 --- /dev/null +++ b/ui/src/demo/handlers/cron.ts @@ -0,0 +1,9 @@ +import { http, HttpResponse } from 'msw' + +export const cronHandlers = [ + http.get('/api/cron/jobs', () => HttpResponse.json({ jobs: [] })), + http.post('/api/cron/jobs', () => HttpResponse.json({ id: 'demo-job' })), + http.put('/api/cron/jobs/:id', () => new HttpResponse(null, { status: 204 })), + http.delete('/api/cron/jobs/:id', () => new HttpResponse(null, { status: 204 })), + http.post('/api/cron/jobs/:id/run', () => new HttpResponse(null, { status: 204 })), +] diff --git a/ui/src/demo/handlers/devMisc.ts b/ui/src/demo/handlers/devMisc.ts new file mode 100644 index 000000000..2cc50c074 --- /dev/null +++ b/ui/src/demo/handlers/devMisc.ts @@ -0,0 +1,27 @@ +import { http, HttpResponse } from 'msw' + +export const devMiscHandlers = [ + http.get('/api/dev/registry', () => + HttpResponse.json({ connectors: [], lastInteraction: null }), + ), + http.post('/api/dev/send', () => HttpResponse.json({ ok: true })), + http.get('/api/dev/sessions', () => HttpResponse.json({ sessions: [] })), + + http.get('/api/version', () => + HttpResponse.json({ + current: '0.21.0-demo', + latest: null, + hasUpdate: false, + releaseUrl: null, + releaseNotes: null, + publishedAt: null, + error: null, + }), + ), + + http.get('/api/topology', () => + HttpResponse.json({ eventTypes: [], producers: [], listeners: [] }), + ), + + http.get('/api/media/:date/:name', () => new HttpResponse(null, { status: 404 })), +] diff --git a/ui/src/demo/handlers/events.ts b/ui/src/demo/handlers/events.ts new file mode 100644 index 000000000..1b922e3de --- /dev/null +++ b/ui/src/demo/handlers/events.ts @@ -0,0 +1,35 @@ +import { http, HttpResponse } from 'msw' +import { demoEvent } from '../fixtures/events' + +// SSE: emit one canned event then leave the stream open. The UI's connectSSE +// treats stream close as an error and reconnects with backoff, so we MUST NOT +// close the controller. Stage 2 will replace this with a scripted timeline. +function eventsStream(): Response { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(demoEvent)}\n\n`)) + }, + }) + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} + +export const eventsHandlers = [ + http.get('/api/events', () => + HttpResponse.json({ entries: [demoEvent], total: 1, page: 1, pageSize: 50, totalPages: 1 }), + ), + http.get('/api/events/recent', () => + HttpResponse.json({ entries: [demoEvent], lastSeq: demoEvent.seq }), + ), + http.get('/api/events/stream', () => eventsStream()), + http.post('/api/events/ingest', () => HttpResponse.json(demoEvent, { status: 201 })), + http.get('/api/events/auth-status', () => + HttpResponse.json({ configured: false, tokenCount: 0, tokenIds: [] }), + ), +] diff --git a/ui/src/demo/handlers/inbox.ts b/ui/src/demo/handlers/inbox.ts new file mode 100644 index 000000000..17a7527d4 --- /dev/null +++ b/ui/src/demo/handlers/inbox.ts @@ -0,0 +1,9 @@ +import { http, HttpResponse } from 'msw' + +export const inboxHandlers = [ + http.get('/api/inbox/history', () => HttpResponse.json({ entries: [], hasMore: false })), + http.post('/api/inbox/seed', () => + HttpResponse.json({ error: 'Demo mode — inbox seed is disabled.' }, { status: 400 }), + ), + http.delete('/api/inbox/:id', () => new HttpResponse(null, { status: 204 })), +] diff --git a/ui/src/demo/handlers/index.ts b/ui/src/demo/handlers/index.ts new file mode 100644 index 000000000..1cc4d7a91 --- /dev/null +++ b/ui/src/demo/handlers/index.ts @@ -0,0 +1,38 @@ +import { authHandlers } from './auth' +import { tradingHandlers } from './trading' +import { workspacesHandlers } from './workspaces' +import { eventsHandlers } from './events' +import { chatHandlers } from './chat' +import { inboxHandlers } from './inbox' +import { personaHeartbeatHandlers } from './personaHeartbeat' +import { channelsHandlers } from './channels' +import { cronHandlers } from './cron' +import { toolsSimulatorHandlers } from './toolsSimulator' +import { marketHandlers } from './market' +import { configKeysHandlers } from './configKeys' +import { agentStatusHandlers } from './agentStatus' +import { notificationsNewsHandlers } from './notificationsNews' +import { devMiscHandlers } from './devMisc' +import { catchAllHandlers } from './catchAll' + +// Order matters: catchAll must be LAST. MSW resolves handlers in registration +// order; catchAll's broad `/api/*` pattern would shadow specific routes if +// placed earlier. +export const handlers = [ + ...authHandlers, + ...tradingHandlers, + ...workspacesHandlers, + ...eventsHandlers, + ...chatHandlers, + ...inboxHandlers, + ...personaHeartbeatHandlers, + ...channelsHandlers, + ...cronHandlers, + ...toolsSimulatorHandlers, + ...marketHandlers, + ...configKeysHandlers, + ...agentStatusHandlers, + ...notificationsNewsHandlers, + ...devMiscHandlers, + ...catchAllHandlers, +] diff --git a/ui/src/demo/handlers/market.ts b/ui/src/demo/handlers/market.ts new file mode 100644 index 000000000..29c360f2f --- /dev/null +++ b/ui/src/demo/handlers/market.ts @@ -0,0 +1,31 @@ +import { http, HttpResponse } from 'msw' + +export const marketHandlers = [ + http.get('/api/market/search', () => HttpResponse.json({ results: [], count: 0 })), + + http.get('/api/market-data-v1/:assetClass/price/historical', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode — market data is not available.' }), + ), + http.get('/api/market-data-v1/equity/profile', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/price/quote', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/fundamental/metrics', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/fundamental/ratios', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/fundamental/balance', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/fundamental/income', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.get('/api/market-data-v1/equity/fundamental/cash', () => + HttpResponse.json({ results: null, provider: 'demo', error: 'Demo mode.' }), + ), + http.post('/api/market-data/test-provider', () => HttpResponse.json({ ok: true })), +] diff --git a/ui/src/demo/handlers/notificationsNews.ts b/ui/src/demo/handlers/notificationsNews.ts new file mode 100644 index 000000000..21c02050f --- /dev/null +++ b/ui/src/demo/handlers/notificationsNews.ts @@ -0,0 +1,6 @@ +import { http, HttpResponse } from 'msw' + +export const notificationsNewsHandlers = [ + http.get('/api/notifications/history', () => HttpResponse.json({ entries: [], hasMore: false })), + http.get('/api/news', () => HttpResponse.json({ items: [], count: 0, lookback: '24h' })), +] diff --git a/ui/src/demo/handlers/personaHeartbeat.ts b/ui/src/demo/handlers/personaHeartbeat.ts new file mode 100644 index 000000000..1b787824c --- /dev/null +++ b/ui/src/demo/handlers/personaHeartbeat.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from 'msw' + +export const personaHeartbeatHandlers = [ + http.get('/api/persona', () => + HttpResponse.json({ content: '# Demo Persona\n\nDemo mode — persona is read-only.', path: '/demo/persona.md' }), + ), + http.put('/api/persona', () => HttpResponse.json({ ok: true })), + + http.get('/api/heartbeat/status', () => HttpResponse.json({ enabled: false })), + http.post('/api/heartbeat/trigger', () => new HttpResponse(null, { status: 204 })), + http.put('/api/heartbeat/enabled', () => HttpResponse.json({ enabled: false })), + http.get('/api/heartbeat/prompt-file', () => + HttpResponse.json({ content: '', path: '/demo/heartbeat.md' }), + ), + http.put('/api/heartbeat/prompt-file', () => new HttpResponse(null, { status: 204 })), +] diff --git a/ui/src/demo/handlers/toolsSimulator.ts b/ui/src/demo/handlers/toolsSimulator.ts new file mode 100644 index 000000000..8df092c2a --- /dev/null +++ b/ui/src/demo/handlers/toolsSimulator.ts @@ -0,0 +1,24 @@ +import { http, HttpResponse } from 'msw' + +export const toolsSimulatorHandlers = [ + http.get('/api/tools', () => HttpResponse.json({ inventory: [], disabled: [] })), + http.put('/api/tools', () => HttpResponse.json({ disabled: [] })), + http.get('/api/tools/:name', () => + HttpResponse.json({ error: 'not found' }, { status: 404 }), + ), + http.post('/api/tools/:name/execute', () => + HttpResponse.json({ content: [{ type: 'text', text: 'Demo mode — tool execution is disabled.' }], isError: true }), + ), + + http.get('/api/simulator/utas', () => HttpResponse.json({ utas: [] })), + http.get('/api/simulator/uta/:id/state', () => + HttpResponse.json({ positions: [], orders: [], cash: '0' }), + ), + http.post('/api/simulator/uta/:id/mark-price', () => HttpResponse.json({ filled: [] })), + http.post('/api/simulator/uta/:id/tick-price', () => HttpResponse.json({ filled: [] })), + http.post('/api/simulator/uta/:id/orders/:orderId/fill', () => HttpResponse.json({ ok: true })), + http.post('/api/simulator/uta/:id/orders/:orderId/cancel', () => HttpResponse.json({ ok: true })), + http.post('/api/simulator/uta/:id/external-deposit', () => HttpResponse.json({ ok: true })), + http.post('/api/simulator/uta/:id/external-withdraw', () => HttpResponse.json({ ok: true })), + http.post('/api/simulator/uta/:id/external-trade', () => HttpResponse.json({ ok: true })), +] diff --git a/ui/src/demo/handlers/trading.ts b/ui/src/demo/handlers/trading.ts new file mode 100644 index 000000000..203c69b04 --- /dev/null +++ b/ui/src/demo/handlers/trading.ts @@ -0,0 +1,103 @@ +import { http, HttpResponse } from 'msw' +import { + demoTradingAccount, + demoUTASummary, + demoAccountInfo, + demoUTAConfig, + DEMO_UTA_ID, +} from '../fixtures/trading' + +export const tradingHandlers = [ + http.get('/api/trading/uta', () => + HttpResponse.json({ + utas: [demoTradingAccount], + summaries: [demoUTASummary], + }), + ), + + http.get('/api/trading/equity', () => + HttpResponse.json({ + totalEquity: '10000.00', + totalCash: '10000.00', + totalUnrealizedPnL: '0.00', + totalRealizedPnL: '0.00', + accounts: [ + { id: DEMO_UTA_ID, label: 'Demo Paper Account', equity: '10000.00', cash: '10000.00' }, + ], + }), + ), + + http.get('/api/trading/fx-rates', () => HttpResponse.json({ rates: [] })), + + http.post('/api/trading/uta/:id/reconnect', () => + HttpResponse.json({ success: true, message: 'Demo mode — reconnect is a no-op.' }), + ), + + http.get('/api/trading/uta/:id/account', () => HttpResponse.json(demoAccountInfo)), + http.get('/api/trading/uta/:id/positions', () => HttpResponse.json({ positions: [] })), + http.get('/api/trading/uta/:id/orders', () => HttpResponse.json({ orders: [] })), + http.get('/api/trading/uta/:id/market-clock', () => + HttpResponse.json({ + isOpen: false, + nextOpen: new Date(Date.now() + 3600_000).toISOString(), + nextClose: new Date(Date.now() + 7 * 3600_000).toISOString(), + }), + ), + + http.get('/api/trading/uta/:id/wallet/status', () => + HttpResponse.json({ staged: [], pendingMessage: null, head: null, commitCount: 0 }), + ), + http.get('/api/trading/uta/:id/wallet/log', () => HttpResponse.json({ commits: [] })), + http.get('/api/trading/uta/:id/wallet/show/:hash', () => + HttpResponse.json({ error: 'not found' }, { status: 404 }), + ), + http.post('/api/trading/uta/:id/wallet/reject', () => + HttpResponse.json({ hash: 'demo', message: 'rejected', operationCount: 0 }), + ), + http.post('/api/trading/uta/:id/wallet/push', () => + HttpResponse.json({ + hash: 'demo', + message: 'demo push', + operationCount: 0, + submitted: [], + rejected: [], + }), + ), + http.post('/api/trading/uta/:id/wallet/place-order', () => + HttpResponse.json( + { error: 'Demo mode — orders are read-only.', phase: 'validate' }, + { status: 400 }, + ), + ), + http.post('/api/trading/uta/:id/wallet/close-position', () => + HttpResponse.json( + { error: 'Demo mode — orders are read-only.', phase: 'validate' }, + { status: 400 }, + ), + ), + http.post('/api/trading/uta/:id/wallet/cancel-order', () => + HttpResponse.json( + { error: 'Demo mode — orders are read-only.', phase: 'validate' }, + { status: 400 }, + ), + ), + + http.get('/api/trading/config/broker-presets', () => HttpResponse.json({ presets: [] })), + http.get('/api/trading/config', () => HttpResponse.json({ utas: [demoUTAConfig] })), + http.post('/api/trading/config/uta', () => HttpResponse.json(demoUTAConfig, { status: 201 })), + http.put('/api/trading/config/uta/:id', () => HttpResponse.json(demoUTAConfig)), + http.delete('/api/trading/config/uta/:id', () => HttpResponse.json({ ok: true })), + http.post('/api/trading/config/test-connection', () => + HttpResponse.json({ success: true, account: demoAccountInfo }), + ), + + http.get('/api/trading/uta/:id/snapshots', () => HttpResponse.json({ snapshots: [] })), + http.delete('/api/trading/uta/:id/snapshots/:timestamp', () => + HttpResponse.json({ success: true }), + ), + http.get('/api/trading/snapshots/equity-curve', () => HttpResponse.json({ points: [] })), + + http.get('/api/trading/contracts/search', () => + HttpResponse.json({ results: [], count: 0, utasConfigured: 1 }), + ), +] diff --git a/ui/src/demo/handlers/workspaces.ts b/ui/src/demo/handlers/workspaces.ts new file mode 100644 index 000000000..05f163f55 --- /dev/null +++ b/ui/src/demo/handlers/workspaces.ts @@ -0,0 +1,57 @@ +import { http, HttpResponse } from 'msw' +import { demoWorkspace, demoTemplate } from '../fixtures/workspaces' + +export const workspacesHandlers = [ + http.get('/api/workspaces', () => HttpResponse.json({ workspaces: [demoWorkspace] })), + http.post('/api/workspaces', () => + HttpResponse.json( + { ok: false, status: 400, error: { error: 'bootstrap_failed', message: 'Demo mode — workspace creation is disabled.' } }, + { status: 400 }, + ), + ), + http.delete('/api/workspaces/:id', () => HttpResponse.json(true)), + http.post('/api/workspaces/:id/stop', () => HttpResponse.json(true)), + + http.get('/api/workspaces/templates', () => HttpResponse.json({ templates: [demoTemplate] })), + http.get('/api/workspaces/templates/:name/readme', () => + HttpResponse.text('', { status: 404 }), + ), + + http.get('/api/workspaces/agents', () => HttpResponse.json({ agents: [] })), + http.get('/api/workspaces/agent-profiles', () => HttpResponse.json({ profiles: [] })), + + http.get('/api/workspaces/:id/git/log', () => HttpResponse.json({ entries: [] })), + http.get('/api/workspaces/:id/git/status', () => + HttpResponse.json({ branch: 'main', clean: true, files: [] }), + ), + http.get('/api/workspaces/:id/files', () => + HttpResponse.json({ path: '/', entries: [] }), + ), + http.get('/api/workspaces/:id/file', () => + HttpResponse.json({ error: 'Demo mode — file contents are not available.' }, { status: 404 }), + ), + + http.post('/api/workspaces/:id/sessions/spawn', ({ params }) => + HttpResponse.json({ + sessionId: 'demo-session', + wsId: String(params.id), + name: 'c1', + pid: 0, + startedAt: Date.now(), + agent: 'claude', + agentSessionId: null, + }), + ), + http.post('/api/workspaces/:id/sessions/:sid/pause', () => HttpResponse.json(true)), + http.post('/api/workspaces/:id/sessions/:sid/resume', () => HttpResponse.json(null)), + http.delete('/api/workspaces/:id/sessions/:sid', () => HttpResponse.json(true)), + http.get('/api/workspaces/:id/sessions/:sid/diagnostics', () => + HttpResponse.json({ status: 'demo' }), + ), + + http.get('/api/workspaces/:id/agent-config', () => HttpResponse.json({})), + http.put('/api/workspaces/:id/agent-config/:agent', () => HttpResponse.json({ ok: true })), + http.post('/api/workspaces/:id/agent-config/:agent/test', () => + HttpResponse.json({ ok: true, response: 'Demo mode — test is stubbed.' }), + ), +] diff --git a/ui/src/demo/index.ts b/ui/src/demo/index.ts new file mode 100644 index 000000000..f8c28d87a --- /dev/null +++ b/ui/src/demo/index.ts @@ -0,0 +1,8 @@ +import { worker } from './worker' + +export async function startWorker(): Promise { + await worker.start({ + onUnhandledRequest: 'bypass', + quiet: false, + }) +} diff --git a/ui/src/demo/worker.ts b/ui/src/demo/worker.ts new file mode 100644 index 000000000..4dd03f093 --- /dev/null +++ b/ui/src/demo/worker.ts @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw/browser' +import { handlers } from './handlers' + +export const worker = setupWorker(...handlers) diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 822cdc2c2..559e4e173 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -7,6 +7,10 @@ import { AuthProvider } from './auth/AuthContext' import { AuthGate } from './auth/AuthGate' import './index.css' +if (import.meta.env.VITE_DEMO_MODE) { + await (await import('./demo')).startWorker() +} + createRoot(document.getElementById('root')!).render( diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 000000000..def41c5e3 --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_DEMO_MODE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}