From b2ec8b45e1869f23f206c3964c3efd3dcab968bb Mon Sep 17 00:00:00 2001 From: yny4ik Date: Thu, 27 Aug 2026 16:15:49 +0300 Subject: [PATCH 1/7] chore(web-app): remove unused components and leftover UI packages Nothing in the live route tree imported these screens, barrels, or shadcn wrappers, so drop them with the radix/window deps that only they used. Co-authored-by: Cursor --- web/app/bun.lock | 140 +---- web/app/package.json | 12 - web/app/src/components/apps/FilterSheet.tsx | 257 --------- web/app/src/components/apps/index.ts | 6 - web/app/src/components/chat/AppSelector.tsx | 212 -------- .../components/conversations/SearchBar.tsx | 171 ------ .../components/layout/MobileBlockOverlay.tsx | 215 -------- web/app/src/components/marketplace/index.ts | 29 -- .../marketplace/plugin-card/index.tsx | 142 ----- .../recaps/sections/OverviewSection.tsx | 92 ---- .../src/components/settings/PlansSheet.tsx | 492 ------------------ .../components/tasks/UpcomingTasksCard.tsx | 130 ----- web/app/src/components/tasks/WeekStrip.tsx | 120 ----- web/app/src/components/tasks/index.ts | 7 - web/app/src/components/ui/BetaRibbon.tsx | 65 --- web/app/src/components/ui/OmiOrb.tsx | 200 ------- web/app/src/components/ui/button.tsx | 55 -- web/app/src/components/ui/dialog.tsx | 113 ---- web/app/src/components/ui/dropdown-menu.tsx | 196 ------- web/app/src/components/ui/scroll-area.tsx | 47 -- web/app/src/components/ui/separator.tsx | 26 - web/app/src/components/ui/skeleton.tsx | 12 - web/app/src/components/ui/tabs.tsx | 53 -- web/app/src/components/ui/tooltip.tsx | 30 -- web/app/src/hooks/useScores.ts | 20 - 25 files changed, 1 insertion(+), 2841 deletions(-) delete mode 100644 web/app/src/components/apps/FilterSheet.tsx delete mode 100644 web/app/src/components/apps/index.ts delete mode 100644 web/app/src/components/chat/AppSelector.tsx delete mode 100644 web/app/src/components/conversations/SearchBar.tsx delete mode 100644 web/app/src/components/layout/MobileBlockOverlay.tsx delete mode 100644 web/app/src/components/marketplace/index.ts delete mode 100644 web/app/src/components/marketplace/plugin-card/index.tsx delete mode 100644 web/app/src/components/recaps/sections/OverviewSection.tsx delete mode 100644 web/app/src/components/settings/PlansSheet.tsx delete mode 100644 web/app/src/components/tasks/UpcomingTasksCard.tsx delete mode 100644 web/app/src/components/tasks/WeekStrip.tsx delete mode 100644 web/app/src/components/tasks/index.ts delete mode 100644 web/app/src/components/ui/BetaRibbon.tsx delete mode 100644 web/app/src/components/ui/OmiOrb.tsx delete mode 100644 web/app/src/components/ui/button.tsx delete mode 100644 web/app/src/components/ui/dialog.tsx delete mode 100644 web/app/src/components/ui/dropdown-menu.tsx delete mode 100644 web/app/src/components/ui/scroll-area.tsx delete mode 100644 web/app/src/components/ui/separator.tsx delete mode 100644 web/app/src/components/ui/skeleton.tsx delete mode 100644 web/app/src/components/ui/tabs.tsx delete mode 100644 web/app/src/components/ui/tooltip.tsx delete mode 100644 web/app/src/hooks/useScores.ts diff --git a/web/app/bun.lock b/web/app/bun.lock index 30d2f6e3c0b..cc6c775ee74 100644 --- a/web/app/bun.lock +++ b/web/app/bun.lock @@ -5,14 +5,7 @@ "": { "name": "omi-web-app", "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-separator": "^1.1.15", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.21", - "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-virtual": "^3.13.18", "@tschk/moonshine": "^0.3.7", "@tschk/moonshine-compiler": "^0.3.7", @@ -26,7 +19,6 @@ "@types/lodash": "^4.17.13", "@types/three": "^0.182.0", "canvas-confetti": "^1.9.4", - "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "firebase": "^11.8.1", "framer-motion": "^11.5.4", @@ -41,8 +33,6 @@ "react-force-graph-3d": "^1.29.0", "react-leaflet": "^5.0.0", "react-markdown": "^10.1.0", - "react-virtualized-auto-sizer": "^2.0.2", - "react-window": "^2.2.3", "streamdown": "^2.5.0", "tailwind-merge": "^2.5.2", "three": "^0.182.0", @@ -58,8 +48,6 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", - "@types/react-virtualized-auto-sizer": "^1.0.4", - "@types/react-window": "^1.8.8", "autoprefixer": "^10.4.23", "eslint": "^9", "jsdom": "^29.1.1", @@ -231,14 +219,6 @@ "@firebase/webchannel-wrapper": ["@firebase/webchannel-wrapper@1.0.3", "", {}, "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ=="], - "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.9.16", "", { "dependencies": { "@grpc/proto-loader": "^0.7.8", "@types/node": ">=12.12.47" } }, "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -305,57 +285,29 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], - - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], - - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="], - - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -365,18 +317,8 @@ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA=="], @@ -563,10 +505,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/react-virtualized-auto-sizer": ["@types/react-virtualized-auto-sizer@1.0.4", "", { "dependencies": { "@types/react": "*" } }, "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A=="], - - "@types/react-window": ["@types/react-window@1.8.8", "", { "dependencies": { "@types/react": "*" } }, "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q=="], - "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], "@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="], @@ -671,8 +609,6 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1285,10 +1221,6 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@2.0.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-nonmCSUIh5HtbzazGcQ1NhnMFps/ZBu/UKJyhCt0Fhi7ondLAUZNETtRCWM8RWYZDzVlMYOQGgBmIxUutIhqgw=="], - - "react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="], - "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -1521,54 +1453,6 @@ "@firebase/messaging/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - - "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-tabs/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], - - "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], - - "@radix-ui/react-tabs/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], - - "@radix-ui/react-tabs/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], - - "@radix-ui/react-tabs/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - - "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], - - "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1615,24 +1499,6 @@ "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "@radix-ui/react-roving-focus/@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], - - "@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], - - "@radix-ui/react-tabs/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - - "@radix-ui/react-tabs/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], - - "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], - - "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], @@ -1640,9 +1506,5 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], } } diff --git a/web/app/package.json b/web/app/package.json index 21903cc7a39..eaddd44c5f0 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -15,14 +15,7 @@ "check": "bun run typecheck && bun run test" }, "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-separator": "^1.1.15", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.21", - "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-virtual": "^3.13.18", "@tschk/moonshine": "^0.3.7", "@tschk/moonshine-compiler": "^0.3.7", @@ -36,7 +29,6 @@ "@types/lodash": "^4.17.13", "@types/three": "^0.182.0", "canvas-confetti": "^1.9.4", - "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "firebase": "^11.8.1", "framer-motion": "^11.5.4", @@ -51,8 +43,6 @@ "react-force-graph-3d": "^1.29.0", "react-leaflet": "^5.0.0", "react-markdown": "^10.1.0", - "react-virtualized-auto-sizer": "^2.0.2", - "react-window": "^2.2.3", "streamdown": "^2.5.0", "tailwind-merge": "^2.5.2", "three": "^0.182.0", @@ -68,8 +58,6 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", - "@types/react-virtualized-auto-sizer": "^1.0.4", - "@types/react-window": "^1.8.8", "autoprefixer": "^10.4.23", "eslint": "^9", "jsdom": "^29.1.1", diff --git a/web/app/src/components/apps/FilterSheet.tsx b/web/app/src/components/apps/FilterSheet.tsx deleted file mode 100644 index d5454281a9f..00000000000 --- a/web/app/src/components/apps/FilterSheet.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { X, Star, Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { AppCategory, AppCapability, AppsFilters, SortOption } from '@/types/apps'; - -interface FilterSheetProps { - open: boolean; - onClose: () => void; - filters: AppsFilters; - onFiltersChange: (filters: AppsFilters) => void; - categories: AppCategory[]; - capabilities: AppCapability[]; -} - -const RATING_OPTIONS = [ - { value: 4, label: '4+ Stars' }, - { value: 3, label: '3+ Stars' }, - { value: 2, label: '2+ Stars' }, - { value: 1, label: '1+ Stars' }, -]; - -const SORT_OPTIONS: { value: SortOption; label: string }[] = [ - { value: 'installs_desc', label: 'Most Installs' }, - { value: 'rating_desc', label: 'Highest Rated' }, - { value: 'rating_asc', label: 'Lowest Rated' }, - { value: 'name_asc', label: 'A-Z' }, - { value: 'name_desc', label: 'Z-A' }, -]; - -export function FilterSheet({ - open, - onClose, - filters, - onFiltersChange, - categories, - capabilities, -}: FilterSheetProps) { - const [localFilters, setLocalFilters] = useState(filters); - - // Sync with external filters when sheet opens - useEffect(() => { - if (open) { - setLocalFilters(filters); - } - }, [open, filters]); - - const handleApply = () => { - onFiltersChange(localFilters); - onClose(); - }; - - const handleReset = () => { - setLocalFilters({}); - }; - - const toggleCategory = (categoryId: string) => { - setLocalFilters((prev) => ({ - ...prev, - category: prev.category === categoryId ? undefined : categoryId, - })); - }; - - const toggleCapability = (capabilityId: string) => { - setLocalFilters((prev) => ({ - ...prev, - capability: prev.capability === capabilityId ? undefined : capabilityId, - })); - }; - - const toggleRating = (rating: number) => { - setLocalFilters((prev) => ({ - ...prev, - rating: prev.rating === rating ? undefined : rating, - })); - }; - - const toggleSort = (sort: SortOption) => { - setLocalFilters((prev) => ({ - ...prev, - sort: prev.sort === sort ? undefined : sort, - })); - }; - - return ( - - {open && ( - <> - {/* Backdrop */} - - - {/* Sheet */} - - {/* Header */} -
-

Filters

- -
- - {/* Content */} -
- {/* Categories */} - -
- {categories.map((category) => ( - toggleCategory(category.id)} - /> - ))} -
-
- - {/* Capabilities */} - -
- {capabilities.map((capability) => ( - toggleCapability(capability.id)} - /> - ))} -
-
- - {/* Rating */} - -
- {RATING_OPTIONS.map((option) => ( - } - selected={localFilters.rating === option.value} - onClick={() => toggleRating(option.value)} - /> - ))} -
-
- - {/* Sort */} - -
- {SORT_OPTIONS.map((option) => ( - toggleSort(option.value)} - /> - ))} -
-
-
- - {/* Footer */} -
- - -
-
- - )} -
- ); -} - -function FilterSection({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

{title}

- {children} -
- ); -} - -interface FilterChipProps { - label: string; - icon?: React.ReactNode; - selected: boolean; - onClick: () => void; -} - -function FilterChip({ label, icon, selected, onClick }: FilterChipProps) { - return ( - - ); -} diff --git a/web/app/src/components/apps/index.ts b/web/app/src/components/apps/index.ts deleted file mode 100644 index bb1f427abcd..00000000000 --- a/web/app/src/components/apps/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { AppsExplorer } from './AppsExplorer'; -export { AppCard } from './AppCard'; -export { AppGridSection } from './AppGridSection'; -export { AppDetail } from './AppDetail'; -export { AppForm } from './AppForm'; -export { ConnectedServices } from './ConnectedServices'; diff --git a/web/app/src/components/chat/AppSelector.tsx b/web/app/src/components/chat/AppSelector.tsx deleted file mode 100644 index f19070f0601..00000000000 --- a/web/app/src/components/chat/AppSelector.tsx +++ /dev/null @@ -1,212 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { ChevronDown, Check, Sparkles, Loader2 } from 'lucide-react'; -import Image from '@tschk/moonshine-next/image'; -import { motion, AnimatePresence } from 'framer-motion'; -import { cn } from '@/lib/utils'; -import { getChatApps, type App } from '@/lib/api'; - -interface AppSelectorProps { - selectedAppId: string | null; - onSelectApp: (appId: string | null) => void; - disabled?: boolean; -} - -export function AppSelector({ selectedAppId, onSelectApp, disabled }: AppSelectorProps) { - const [isOpen, setIsOpen] = useState(false); - const [apps, setApps] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const dropdownRef = useRef(null); - - // Load chat apps - useEffect(() => { - async function loadApps() { - try { - const chatApps = await getChatApps(); - setApps(chatApps); - } catch (err) { - console.error('Failed to load chat apps:', err); - } finally { - setIsLoading(false); - } - } - loadApps(); - }, []); - - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const selectedApp = apps.find(app => app.id === selectedAppId); - - return ( -
- {/* Trigger button */} - - - {/* Dropdown menu */} - - {isOpen && ( - - {isLoading ? ( -
- -
- ) : ( -
- {/* Default Omi option */} - - - {/* Separator if there are apps */} - {apps.length > 0 && ( -
- )} - - {/* Chat apps list */} - {apps.map(app => ( - - ))} - - {/* Empty state */} - {apps.length === 0 && ( -
-

- No chat apps enabled -

-

- Enable apps in the Apps section -

-
- )} -
- )} - - )} - -
- ); -} diff --git a/web/app/src/components/conversations/SearchBar.tsx b/web/app/src/components/conversations/SearchBar.tsx deleted file mode 100644 index ed7b2687361..00000000000 --- a/web/app/src/components/conversations/SearchBar.tsx +++ /dev/null @@ -1,171 +0,0 @@ -'use client'; - -import { useState, useCallback, useEffect, useRef } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Search, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -interface SearchBarProps { - value: string; - onChange: (value: string) => void; - onSearch: (query: string) => void; - placeholder?: string; - debounceMs?: number; - className?: string; -} - -export function SearchBar({ - value, - onChange, - onSearch, - placeholder = 'Search conversations...', - debounceMs = 300, - className, -}: SearchBarProps) { - const [isFocused, setIsFocused] = useState(false); - const [isHovered, setIsHovered] = useState(false); - const [isMac, setIsMac] = useState(true); - const inputRef = useRef(null); - const debounceRef = useRef(null); - - // Detect OS for keyboard shortcut display - useEffect(() => { - setIsMac(navigator.platform.toLowerCase().includes('mac')); - }, []); - - // Handle input change with debounce - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value; - onChange(newValue); - - // Clear previous debounce - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - - // Set new debounce for search - debounceRef.current = setTimeout(() => { - onSearch(newValue); - }, debounceMs); - }, - [onChange, onSearch, debounceMs], - ); - - // Clear search - const handleClear = useCallback(() => { - onChange(''); - onSearch(''); - inputRef.current?.focus(); - }, [onChange, onSearch]); - - // Keyboard shortcut: Cmd+K or Ctrl+K to focus search - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - inputRef.current?.focus(); - } - // Escape to blur and clear if empty - if (e.key === 'Escape' && document.activeElement === inputRef.current) { - if (value === '') { - inputRef.current?.blur(); - } else { - handleClear(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [value, handleClear]); - - // Cleanup debounce on unmount - useEffect(() => { - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - }; - }, []); - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- {/* Search icon */} - - - {/* Input */} - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={placeholder} - className={cn( - 'flex-1 bg-transparent text-sm text-text-primary', - 'placeholder:text-text-quaternary', - 'outline-none', - )} - /> - - {/* Keyboard shortcut hint or clear button */} - - {value ? ( - - - - ) : ( - - {isMac ? '⌘' : 'Ctrl'} - K - - )} - -
-
- ); -} diff --git a/web/app/src/components/layout/MobileBlockOverlay.tsx b/web/app/src/components/layout/MobileBlockOverlay.tsx deleted file mode 100644 index 74ec2c8fa73..00000000000 --- a/web/app/src/components/layout/MobileBlockOverlay.tsx +++ /dev/null @@ -1,215 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { usePathname } from '@tschk/moonshine-next/navigation'; -import Image from '@tschk/moonshine-next/image'; -import { X } from 'lucide-react'; - -type Platform = 'ios' | 'android' | 'other'; - -// Paths that should completely bypass mobile detection -const BYPASS_MOBILE_CHECK_PATHS = [ - '/record/popout', - '/record/popout/transcript', - '/apps', // Public marketplace should always be accessible on mobile -]; - -const APP_STORE_URL = 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163'; -const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.friend.ios'; - -// Debounce delay for resize detection (ms) -const RESIZE_DEBOUNCE_MS = 500; - -// Session storage key for dismissal -const DISMISSED_KEY = 'omi_mobile_overlay_dismissed'; - -function AppleIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function PlayStoreIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -export function MobileBlockOverlay() { - const pathname = usePathname(); - const [isMobile, setIsMobile] = useState(false); - const [platform, setPlatform] = useState('other'); - const [mounted, setMounted] = useState(false); - const [isDismissed, setIsDismissed] = useState(false); - const resizeTimeoutRef = useRef(null); - - // Check if current path should bypass mobile detection entirely - const shouldBypass = BYPASS_MOBILE_CHECK_PATHS.some(path => pathname?.startsWith(path)); - - useEffect(() => { - setMounted(true); - - // Check if already dismissed this session - if (typeof window !== 'undefined') { - const dismissed = sessionStorage.getItem(DISMISSED_KEY); - if (dismissed === 'true') { - setIsDismissed(true); - } - } - - // Check viewport width with debounce - const checkMobile = () => { - // Clear any pending timeout - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - - // Only set mobile state after debounce delay - resizeTimeoutRef.current = setTimeout(() => { - setIsMobile(window.innerWidth < 768); - }, RESIZE_DEBOUNCE_MS); - }; - - // Initial check without debounce - setIsMobile(window.innerWidth < 768); - - window.addEventListener('resize', checkMobile); - - // Detect platform via user agent - const ua = navigator.userAgent.toLowerCase(); - if (/iphone|ipad|ipod/.test(ua)) { - setPlatform('ios'); - } else if (/android/.test(ua)) { - setPlatform('android'); - } - - return () => { - window.removeEventListener('resize', checkMobile); - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - }; - }, []); - - const handleDismiss = () => { - setIsDismissed(true); - if (typeof window !== 'undefined') { - sessionStorage.setItem(DISMISSED_KEY, 'true'); - } - }; - - // Don't render anything on server, if not mobile, if dismissed, or if on bypass path - if (!mounted || !isMobile || isDismissed || shouldBypass) return null; - - const storeUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL; - const storeName = platform === 'android' ? 'Google Play' : 'App Store'; - const StoreIcon = platform === 'android' ? PlayStoreIcon : AppleIcon; - - return ( -
- {/* Dismiss button - top right corner */} - - - {/* Background gradient effect */} -
-
-
- - {/* Main content container */} -
- {/* Round logo with breathing glow */} -
-
-
- Omi -
-
- - {/* Message */} -

- Omi Web is optimized for desktop. -

-

- For the best mobile experience, download the Omi app. -

- - {/* App Store Button */} - - - Download on {storeName} - - - {/* Continue to web button */} - -
- - {/* Bottom section with logo and links */} - -
- ); -} diff --git a/web/app/src/components/marketplace/index.ts b/web/app/src/components/marketplace/index.ts deleted file mode 100644 index 70e6b3232dc..00000000000 --- a/web/app/src/components/marketplace/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Types -export type { - ExternalIntegration, - PluginReview, - Plugin, - PluginStat, - CapabilityInfo, -} from './types'; - -// Category utilities -export type { CategoryMetadata, CategoryTheme } from './category'; -export { - categoryMetadata, - getCategoryMetadata, - getAdjacentCategories, - getCategoryDisplay, - getCategoryIcon, -} from './category'; - -// Components -export { default as AppList } from './AppList'; -export { CategoryHeader } from './CategoryHeader'; -export { DeveloperBanner } from './DeveloperBanner'; -export { NewBadge, isNewApp } from './NewBadge'; -export { ScrollableCategoryNav } from './ScrollableCategoryNav'; -export { SearchBar } from './SearchBar'; - -// Plugin Card Components -export { PluginCard, FeaturedPluginCard, CompactPluginCard } from './plugin-card'; diff --git a/web/app/src/components/marketplace/plugin-card/index.tsx b/web/app/src/components/marketplace/plugin-card/index.tsx deleted file mode 100644 index b078b1f5691..00000000000 --- a/web/app/src/components/marketplace/plugin-card/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client'; - -import { memo } from 'react'; -import { - Star, - Download, - Brain, - Cpu, - Bell, - Plug2, - MessageSquare, - Info, -} from 'lucide-react'; -import Image from '@tschk/moonshine-next/image'; -import Link from '@tschk/moonshine-next/link'; -import type { Plugin } from '../types'; -import { formatInstalls } from '../utils/format'; - -export interface PluginCardProps { - plugin: Plugin; -} - -const getCapabilityColor = (capability: string): string => { - const colors: Record = { - 'ai-powered': 'bg-amber-500/15 text-amber-300', - memories: 'bg-rose-500/15 text-rose-300', - notification: 'bg-emerald-500/15 text-emerald-300', - integration: 'bg-sky-500/15 text-sky-300', - chat: 'bg-teal-500/15 text-teal-300', - }; - return colors[capability.toLowerCase()] ?? 'bg-gray-700/20 text-gray-300'; -}; - -const formatCapabilityName = (capability: string): string => { - const nameMap: Record = { - memories: 'memories', - external_integration: 'integration', - proactive_notification: 'notification', - chat: 'chat', - }; - return nameMap[capability.toLowerCase()] ?? capability; -}; - -const getCapabilityIcon = (capability: string) => { - const icons: Record = { - 'ai-powered': Brain, - memories: Cpu, - notification: Bell, - integration: Plug2, - chat: MessageSquare, - }; - return icons[capability.toLowerCase()] ?? Info; -}; - -export const PluginCard = memo(function PluginCard({ plugin }: PluginCardProps) { - return ( - -
- -
-
-
- {plugin.name} -
-
-

- {plugin.name} -

- {plugin.is_paid && ( - - ${plugin.price?.toFixed(2)} - {plugin.payment_plan === 'monthly_recurring' ? '/mo' : ''} - - )} -
-

- {plugin.description} -

-
-
-
- -
- -
- {Array.from(plugin.capabilities).map((cap) => { - const formattedCap = formatCapabilityName(cap); - const Icon = getCapabilityIcon(formattedCap); - return ( - - - {formattedCap} - - ); - })} -
- -
-
-
- {plugin.rating_count > 0 ? ( -
- - - {plugin.rating_avg?.toFixed(1)} - -
- ) : ( - New - )} -
- - {formatInstalls(plugin.installs)} -
-
-
- Learn More -
-
-
-
- - ); -}); - -// Re-export other card variants -export { FeaturedPluginCard } from './FeaturedPluginCard'; -export { CompactPluginCard } from './CompactPluginCard'; diff --git a/web/app/src/components/recaps/sections/OverviewSection.tsx b/web/app/src/components/recaps/sections/OverviewSection.tsx deleted file mode 100644 index f3846f4ed8a..00000000000 --- a/web/app/src/components/recaps/sections/OverviewSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { motion } from 'framer-motion'; -import { MessageSquare, Clock, CheckSquare } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { DailySummary } from '@/types/recap'; - -interface OverviewSectionProps { - recap: DailySummary; -} - -// Format duration in minutes to human-readable -function formatDuration(minutes: number): string { - if (minutes < 60) { - return `${minutes} min`; - } - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return mins > 0 ? `${hours}h ${mins}m` : `${hours} hours`; -} - -export function OverviewSection({ recap }: OverviewSectionProps) { - return ( - - {/* Stats row */} -
- {/* Conversations */} -
- - - {recap.stats.total_conversations} - - Conversations -
- - {/* Duration */} -
- - - {formatDuration(recap.stats.total_duration_minutes)} - - Recorded -
- - {/* Action Items */} -
- - - {recap.stats.action_items_count} - - Action Items -
-
- - {/* Overview text */} - {recap.overview && ( -
-

{recap.overview}

-
- )} -
- ); -} diff --git a/web/app/src/components/settings/PlansSheet.tsx b/web/app/src/components/settings/PlansSheet.tsx deleted file mode 100644 index 4ff2ea91f5a..00000000000 --- a/web/app/src/components/settings/PlansSheet.tsx +++ /dev/null @@ -1,492 +0,0 @@ -'use client'; - -import * as Dialog from '@radix-ui/react-dialog'; -import { motion, AnimatePresence } from 'framer-motion'; -import { X, Check, Crown, CreditCard, Loader2, AlertCircle } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { useState, useEffect } from 'react'; -import { - getAvailablePlans, - createCheckoutSession, - upgradeSubscription, - cancelSubscription, - getCustomerPortal, - getUserSubscription, -} from '@/lib/api'; -import type { - UserSubscription, - PricingOption, - AvailablePlansResponse, -} from '@/types/user'; -import { decodePlan, planGrantsPaidCapability } from '@/types/user'; -import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; - -interface PlansSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - subscription: UserSubscription | null; - onSubscriptionUpdate: () => void; - cachedPlans?: PricingOption[] | null; -} - -export function PlansSheet({ - open, - onOpenChange, - subscription, - onSubscriptionUpdate, - cachedPlans, -}: PlansSheetProps) { - const [pricingOptions, setPricingOptions] = useState([]); - const [selectedPriceId, setSelectedPriceId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingPlans, setIsLoadingPlans] = useState(true); - const [error, setError] = useState(null); - const [showCancelConfirm, setShowCancelConfirm] = useState(false); - const [isCanceling, setIsCanceling] = useState(false); - - const planIdentity = subscription - ? subscription.plan_identity ?? decodePlan(subscription.plan) - : null; - const isUnlimited = planIdentity ? planGrantsPaidCapability(planIdentity) : false; - const isUnknownPlan = planIdentity?.kind === 'unknown'; - const isCanceling_ = subscription?.cancel_at_period_end; - - useEffect(() => { - let cancelled = false; - - if (open) { - if (isUnknownPlan) { - setPricingOptions([]); - setSelectedPriceId(null); - setError(null); - setIsLoadingPlans(false); - return () => { - cancelled = true; - }; - } - - // Use cached plans if available, otherwise fetch - if (cachedPlans && cachedPlans.length > 0) { - if (cancelled) return; - setPricingOptions(cachedPlans); - const activePlan = cachedPlans.find( - (p) => p.is_active || p.id === subscription?.current_price_id, - ); - if (activePlan) { - setSelectedPriceId(activePlan.id); - } else { - setSelectedPriceId(cachedPlans[0].id); - } - setIsLoadingPlans(false); - } else { - loadPlans(cancelled); - } - } - return () => { - cancelled = true; - }; - }, [open, cachedPlans, subscription?.current_price_id, isUnknownPlan]); - - const loadPlans = async (cancelled = false) => { - setIsLoadingPlans(true); - setError(null); - try { - const response = await getAvailablePlans(); - if (cancelled) return; - if (response && response.plans) { - setPricingOptions(response.plans); - // Pre-select current active plan or first plan - const activePlan = response.plans.find( - (p) => p.is_active || p.id === subscription?.current_price_id, - ); - if (activePlan) { - setSelectedPriceId(activePlan.id); - } else if (response.plans.length > 0) { - setSelectedPriceId(response.plans[0].id); - } - } - } catch (err) { - setError('Failed to load plans'); - } finally { - setIsLoadingPlans(false); - } - }; - - const handleSubscribe = async () => { - if (isUnknownPlan || !selectedPriceId) return; - - setIsLoading(true); - setError(null); - - try { - // Find if selected plan is the currently active one - const selectedOption = pricingOptions.find((p) => p.id === selectedPriceId); - const isCurrentPlan = selectedOption?.is_active; - - if (isCanceling_ && selectedPriceId !== subscription?.current_price_id) { - setError('Plan changes are available after your current subscription ends.'); - return; - } - - // If already subscribed and selecting a different plan, use upgrade endpoint - if (isUnlimited && !isCanceling_ && !isCurrentPlan) { - const result = await upgradeSubscription(selectedPriceId); - if (result?.status === 'success' || result?.scheduled_start) { - onSubscriptionUpdate(); - onOpenChange(false); - } else { - setError(result?.message || 'Failed to upgrade plan'); - } - } else { - // New subscription or reactivation - const result = await createCheckoutSession(selectedPriceId); - if (result?.url) { - // Open Stripe checkout in new tab - window.open(result.url, '_blank'); - onOpenChange(false); - // Listen for window focus to refresh subscription - const handleFocus = () => { - onSubscriptionUpdate(); - window.removeEventListener('focus', handleFocus); - }; - window.addEventListener('focus', handleFocus); - } else if (result?.status === 'reactivated') { - // Subscription was reactivated - onSubscriptionUpdate(); - onOpenChange(false); - } else { - setError('Failed to create checkout session'); - } - } - } catch (err) { - setError('An error occurred. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - const handleManagePayment = async () => { - setIsLoading(true); - try { - const result = await getCustomerPortal(); - if (result?.url) { - window.open(result.url, '_blank'); - } else { - setError('Failed to open payment portal'); - } - } catch (err) { - setError('Failed to open payment portal'); - } finally { - setIsLoading(false); - } - }; - - const handleCancelSubscription = async () => { - setIsCanceling(true); - try { - const result = await cancelSubscription(); - if (result?.status === 'success' || result?.cancel_at_period_end) { - onSubscriptionUpdate(); - setShowCancelConfirm(false); - onOpenChange(false); - } else { - setError(result?.message || 'Failed to cancel subscription'); - } - } catch (err) { - setError('Failed to cancel subscription'); - } finally { - setIsCanceling(false); - } - }; - - const formatDate = (timestamp: number) => { - return new Date(timestamp * 1000).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); - }; - - // Sort pricing options: monthly first, then annual - const sortedOptions = [...pricingOptions].sort((a, b) => { - const aIsAnnual = a.interval === 'year' || a.title?.toLowerCase().includes('annual'); - const bIsAnnual = b.interval === 'year' || b.title?.toLowerCase().includes('annual'); - return (aIsAnnual ? 1 : 0) - (bIsAnnual ? 1 : 0); - }); - - // Get the selected option - const selectedOption = pricingOptions.find((p) => p.id === selectedPriceId); - - // Default features for unlimited plan - const defaultFeatures = [ - 'Unlimited conversations', - 'Unlimited memories', - 'Priority processing', - 'Advanced insights', - ]; - - return ( - <> - - - {open && ( - - - - - - - {/* Header */} -
- - - - -
-
- -
-
- - {isUnknownPlan - ? 'Plan unavailable' - : isUnlimited && !isCanceling_ - ? 'Manage Your Plan' - : 'Choose Your Plan'} - - {isUnlimited && subscription?.current_period_end && ( -

- {isCanceling_ - ? `Cancels on ${formatDate(subscription.current_period_end)}` - : `Renews ${formatDate(subscription.current_period_end)}`} -

- )} -
-
-
- - {/* Content */} -
- {isUnknownPlan ? ( -
-

- This account uses a plan that this version of Omi does not - recognize yet. Plan options are unavailable until the plan can - be identified. -

-
- ) : isLoadingPlans ? ( -
- -
- ) : ( - <> - {/* Plan Selection */} -
- {sortedOptions.map((option) => { - const isSelected = selectedPriceId === option.id; - const isCurrent = - option.is_active || - option.id === subscription?.current_price_id; - const isAnnual = - option.interval === 'year' || - option.title?.toLowerCase().includes('annual'); - - return ( - - ); - })} -
- - {isCanceling_ && subscription?.current_period_end && ( -

- You can reactivate your current plan now. Plan changes are - available after {formatDate(subscription.current_period_end)}. -

- )} - - {/* Features List */} -
-

- Features: -

-
    - {defaultFeatures.map((feature, idx) => ( -
  • - - - {feature} - -
  • - ))} -
-
- - {/* Error Message */} - {error && ( -
- -

{error}

-
- )} - - {/* Primary Action */} - - - {!isUnlimited && subscription?.stripe_subscription_id && ( - - )} - - {/* Secondary Actions */} - {isUnlimited && ( -
- - - {!isCanceling_ && ( - - )} -
- )} - - )} -
-
-
-
- )} -
-
- - {/* Cancel Confirmation Dialog */} - - - ); -} diff --git a/web/app/src/components/tasks/UpcomingTasksCard.tsx b/web/app/src/components/tasks/UpcomingTasksCard.tsx deleted file mode 100644 index 2f68abe2022..00000000000 --- a/web/app/src/components/tasks/UpcomingTasksCard.tsx +++ /dev/null @@ -1,130 +0,0 @@ -'use client'; - -import { motion } from 'framer-motion'; -import { Check, Clock, ArrowRight } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { ActionItem } from '@/types/conversation'; - -interface UpcomingTasksCardProps { - tasks: ActionItem[]; - onToggleComplete: (id: string, completed: boolean) => void; - maxItems?: number; -} - -function formatDueText(dueAt: string | null | undefined): string { - if (!dueAt) return ''; - - const due = new Date(dueAt); - const today = new Date(); - today.setHours(0, 0, 0, 0); - due.setHours(0, 0, 0, 0); - - const diffTime = due.getTime() - today.getTime(); - const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays < 0) { - return `${Math.abs(diffDays)}d late`; - } else if (diffDays === 0) { - return 'Today'; - } else if (diffDays === 1) { - return 'Tomorrow'; - } else if (diffDays <= 7) { - return due.toLocaleDateString('en-US', { weekday: 'short' }); - } else { - return due.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - } -} - -function isOverdue(dueAt: string | null | undefined): boolean { - if (!dueAt) return false; - const due = new Date(dueAt); - const today = new Date(); - today.setHours(0, 0, 0, 0); - due.setHours(0, 0, 0, 0); - return due < today; -} - -export function UpcomingTasksCard({ - tasks, - onToggleComplete, - maxItems = 5, -}: UpcomingTasksCardProps) { - // Get upcoming tasks (non-completed, sorted by due date) - const upcomingTasks = tasks - .filter((t) => !t.completed) - .sort((a, b) => { - // Tasks with due dates come first - if (!a.due_at && !b.due_at) return 0; - if (!a.due_at) return 1; - if (!b.due_at) return -1; - return new Date(a.due_at).getTime() - new Date(b.due_at).getTime(); - }) - .slice(0, maxItems); - - if (upcomingTasks.length === 0) { - return null; - } - - return ( -
-
-

- - Coming Up -

- - Next {upcomingTasks.length} - -
- -
- {upcomingTasks.map((task, index) => ( - - {/* Quick complete checkbox */} - - - {/* Task description */} - - {task.description} - - - {/* Due indicator */} - {task.due_at && ( - - - {formatDueText(task.due_at)} - - )} - - ))} -
-
- ); -} diff --git a/web/app/src/components/tasks/WeekStrip.tsx b/web/app/src/components/tasks/WeekStrip.tsx deleted file mode 100644 index a2304370d29..00000000000 --- a/web/app/src/components/tasks/WeekStrip.tsx +++ /dev/null @@ -1,120 +0,0 @@ -'use client'; - -import { motion } from 'framer-motion'; -import { Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -interface DayData { - date: Date; - dayName: string; - dayNumber: number; - isToday: boolean; - pending: number; - completed: number; -} - -interface WeekStripProps { - days: DayData[]; - selectedDate?: Date | null; - onSelectDate?: (date: Date) => void; - onDropTask?: (date: Date, taskId: string) => void; -} - -export function WeekStrip({ - days, - selectedDate, - onSelectDate, - onDropTask, -}: WeekStripProps) { - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.currentTarget.classList.add('ring-2', 'ring-white'); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.currentTarget.classList.remove('ring-2', 'ring-white'); - }; - - const handleDrop = (e: React.DragEvent, date: Date) => { - e.preventDefault(); - e.currentTarget.classList.remove('ring-2', 'ring-white'); - const taskId = e.dataTransfer.getData('taskId'); - if (taskId && onDropTask) { - onDropTask(date, taskId); - } - }; - - return ( -
- {days.map((day, index) => { - const isSelected = selectedDate?.toDateString() === day.date.toDateString(); - const hasNoTasks = day.pending === 0 && day.completed === 0; - const allCompleted = day.pending === 0 && day.completed > 0; - - return ( - onSelectDate?.(day.date)} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, day.date)} - className={cn( - 'flex flex-col items-center min-w-[72px] px-3 py-2 rounded-lg', - 'border transition-all duration-150', - day.isToday - ? 'bg-white/10 border-white/50' - : 'bg-bg-tertiary border-transparent hover:bg-bg-quaternary hover:border-white/30', - isSelected && 'ring-2 ring-white', - )} - > - {/* Day name */} - - {day.dayName} - - - {/* Day number */} - - {day.dayNumber} - - - {/* Badge */} - {!hasNoTasks && ( -
- {allCompleted ? ( -
- -
- ) : ( - - {day.pending} - - )} -
- )} - - {/* Empty spacer to maintain height */} - {hasNoTasks &&
} - - ); - })} -
- ); -} diff --git a/web/app/src/components/tasks/index.ts b/web/app/src/components/tasks/index.ts deleted file mode 100644 index 266a6d3ebfd..00000000000 --- a/web/app/src/components/tasks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { TaskHub } from './TaskHub'; -export { TaskCard, TaskCardSkeleton } from './TaskCard'; -export { TaskGroup, TaskGroupSkeleton } from './TaskGroup'; -export { TaskProgressCard } from './TaskProgressCard'; -export { TaskQuickAdd } from './TaskQuickAdd'; -export { WeekStrip } from './WeekStrip'; -export { BulkActionBar } from './BulkActionBar'; diff --git a/web/app/src/components/ui/BetaRibbon.tsx b/web/app/src/components/ui/BetaRibbon.tsx deleted file mode 100644 index 41463a89bd9..00000000000 --- a/web/app/src/components/ui/BetaRibbon.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { MessageSquare } from 'lucide-react'; - -// Discord icon component -function DiscordIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -export function BetaRibbon() { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - // Don't render on server to avoid hydration issues - if (!mounted) return null; - - // Don't show on mobile (overlay handles it) - if (typeof window !== 'undefined' && window.innerWidth < 1024) return null; - - return ( - <> - {/* Feedback links - positioned to the left of the ribbon */} - - - {/* Beta ribbon */} - - - ); -} diff --git a/web/app/src/components/ui/OmiOrb.tsx b/web/app/src/components/ui/OmiOrb.tsx deleted file mode 100644 index 1d53189469e..00000000000 --- a/web/app/src/components/ui/OmiOrb.tsx +++ /dev/null @@ -1,200 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - OmiMarkGeometry, - omiBurstForTurn, - omiDotPlacement, - omiMotionForState, - omiOrbPlacements, - smoothOmiLevel, - type OmiOrbMotion, - type OmiOrbState, -} from '@/lib/omiOrb'; - -interface OmiOrbProps { - state?: OmiOrbState; - /** Overrides the motion the state would pick. */ - motion?: OmiOrbMotion; - /** Input level, 0 to 1. Drives `sine` and `audioBars`; ignored elsewhere. */ - level?: number; - size?: number; - /** Stable for the life of one wait, so the motion never twitches mid-wait. */ - seed?: number; - className?: string; - paused?: boolean; -} - -/** - * One full lap of each motion. The idle mark turns slowly; the level-metering - * motions have to keep up with speech, so they run much tighter. - */ -const LAP_SECONDS: Record = { - mark: 6, - spin: 6, - pulse: 1.7, - gather: 2.4, - sine: 1.6, - travellingWave: 2.2, - standingWave: 2.4, - pendulumWave: 3, - tusiPendulum: 3, - audioBars: 2, - tusi: 2.6, - nestedOrbit: 3, - doubleCircle: 3, - epicycloid: 2.6, - lissajous: 3, - pendulumSwing: 2.4, - successBurst: 2, -}; - -const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; - -function prefersReducedMotion(): boolean { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') - return false; - return window.matchMedia(REDUCED_MOTION_QUERY).matches; -} - -/** - * Tracks the preference rather than sampling it once: a reader who turns - * reduced motion on mid-session is asking the animation already on screen to - * stop, and a value read at mount can never answer that. - */ -function useReducedMotion(): boolean { - const [reduced, setReduced] = useState(prefersReducedMotion); - - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; - const query = window.matchMedia(REDUCED_MOTION_QUERY); - const update = () => setReduced(query.matches); - update(); - query.addEventListener('change', update); - return () => query.removeEventListener('change', update); - }, []); - - return reduced; -} - -export function OmiOrb({ - state = 'idle', - motion, - level = 0, - size = 46, - seed = 0, - className, - paused = false, -}: OmiOrbProps) { - const active: OmiOrbMotion = motion ?? omiMotionForState(state, seed); - const reduced = useReducedMotion(); - const usesCompositorPulse = active === 'pulse'; - - const dots = useRef<(SVGCircleElement | null)[]>([]); - const levelRef = useRef(level); - const smoothedLevelRef = useRef(level); - levelRef.current = level; - - const unit = size / OmiMarkGeometry.canvas; - const centre = size / 2; - - // The resting frame, used for the server render, for reduced motion, and as - // the first paint before the loop takes over the attributes directly. - const restFrame = useMemo( - () => - omiOrbPlacements({ - motion: reduced ? 'mark' : active, - turn: 0, - level: reduced ? 0 : level, - }), - // `level` is read only for the very first paint; the loop owns it after. - // eslint-disable-next-line react-hooks/exhaustive-deps - [active, reduced], - ); - - useEffect(() => { - if (reduced || paused) return; - const lap = LAP_SECONDS[active] * 1000; - let frame = 0; - let start = 0; - let previous = 0; - - const tick = (now: number) => { - if (start === 0) start = now; - const elapsed = previous === 0 ? 1000 / 60 : now - previous; - previous = now; - smoothedLevelRef.current = smoothOmiLevel( - smoothedLevelRef.current, - levelRef.current, - elapsed, - ); - const turn = ((((now - start) / lap) % 1) + 1) % 1; - for (let i = 0; i < OmiMarkGeometry.dotCount; i++) { - const node = dots.current[i]; - if (!node) continue; - const dot = omiDotPlacement({ - motion: active, - index: i, - turn, - level: smoothedLevelRef.current, - burst: omiBurstForTurn(active, turn), - }); - if (usesCompositorPulse) { - node.style.transform = `scale(${dot.scale})`; - node.style.opacity = String(Math.max(0, Math.min(1, dot.alpha))); - continue; - } - // Mutating attributes directly keeps the tree from re-rendering 60 - // times a second for what is purely a paint change. - node.setAttribute('cx', String(centre + dot.offset.x * unit)); - node.setAttribute('cy', String(centre + dot.offset.y * unit)); - node.setAttribute('r', String(OmiMarkGeometry.dotRadius * unit * dot.scale)); - node.setAttribute('opacity', String(Math.max(0, Math.min(1, dot.alpha)))); - } - frame = requestAnimationFrame(tick); - }; - - frame = requestAnimationFrame(tick); - return () => cancelAnimationFrame(frame); - }, [active, reduced, paused, centre, unit, usesCompositorPulse]); - - return ( - - {restFrame.map((dot, i) => { - const opacity = Math.max(0, Math.min(1, dot.alpha)); - return ( - { - dots.current[i] = node; - }} - cx={centre + dot.offset.x * unit} - cy={centre + dot.offset.y * unit} - r={OmiMarkGeometry.dotRadius * unit * (usesCompositorPulse ? 1 : dot.scale)} - opacity={usesCompositorPulse ? undefined : opacity} - fill="currentColor" - style={ - usesCompositorPulse - ? { - opacity, - transform: `scale(${dot.scale})`, - transformBox: 'fill-box', - transformOrigin: 'center', - } - : undefined - } - /> - ); - })} - - ); -} - -export default OmiOrb; diff --git a/web/app/src/components/ui/button.tsx b/web/app/src/components/ui/button.tsx deleted file mode 100644 index 924e44e0376..00000000000 --- a/web/app/src/components/ui/button.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '@/lib/utils'; - -const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-control text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', - { - variants: { - variant: { - default: 'bg-white text-black hover:bg-white/90', - destructive: 'bg-error text-white hover:bg-error/90', - outline: - 'border border-stroke bg-transparent text-text-primary hover:bg-bg-tertiary', - secondary: 'bg-bg-tertiary text-text-primary hover:bg-bg-quaternary', - ghost: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary', - link: 'text-text-primary underline-offset-4 hover:underline', - }, - size: { - default: 'h-9 px-4 py-2', - sm: 'h-8 rounded-chip px-3 text-xs', - lg: 'h-10 rounded-control px-8', - icon: 'h-9 w-9 rounded-chip', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - }, -); - -export interface ButtonProps - extends - React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : 'button'; - return ( - - ); - }, -); -Button.displayName = 'Button'; - -export { Button, buttonVariants }; diff --git a/web/app/src/components/ui/dialog.tsx b/web/app/src/components/ui/dialog.tsx deleted file mode 100644 index afaa0a0bf7d..00000000000 --- a/web/app/src/components/ui/dialog.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { X } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const Dialog = DialogPrimitive.Root; - -const DialogTrigger = DialogPrimitive.Trigger; - -const DialogPortal = DialogPrimitive.Portal; - -const DialogClose = DialogPrimitive.Close; - -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; - -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogHeader.displayName = 'DialogHeader'; - -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogFooter.displayName = 'DialogFooter'; - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogTitle.displayName = DialogPrimitive.Title.displayName; - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogDescription.displayName = DialogPrimitive.Description.displayName; - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -}; diff --git a/web/app/src/components/ui/dropdown-menu.tsx b/web/app/src/components/ui/dropdown-menu.tsx deleted file mode 100644 index 8eeb92d34cf..00000000000 --- a/web/app/src/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import * as React from 'react'; -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const DropdownMenu = DropdownMenuPrimitive.Root; - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - svg]:size-4 [&>svg]:shrink-0', - inset && 'pl-8', - className, - )} - {...props} - /> -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; diff --git a/web/app/src/components/ui/scroll-area.tsx b/web/app/src/components/ui/scroll-area.tsx deleted file mode 100644 index f7f0ca51f54..00000000000 --- a/web/app/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; - -import { cn } from '@/lib/utils'; - -const ScrollArea = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - {children} - - - - -)); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollBar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = 'vertical', ...props }, ref) => ( - - - -)); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; - -export { ScrollArea, ScrollBar }; diff --git a/web/app/src/components/ui/separator.tsx b/web/app/src/components/ui/separator.tsx deleted file mode 100644 index ddf2c61a1ee..00000000000 --- a/web/app/src/components/ui/separator.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as SeparatorPrimitive from '@radix-ui/react-separator'; - -import { cn } from '@/lib/utils'; - -const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( - -)); -Separator.displayName = SeparatorPrimitive.Root.displayName; - -export { Separator }; diff --git a/web/app/src/components/ui/skeleton.tsx b/web/app/src/components/ui/skeleton.tsx deleted file mode 100644 index 97efbe06ba7..00000000000 --- a/web/app/src/components/ui/skeleton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { cn } from '@/lib/utils'; - -function Skeleton({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); -} - -export { Skeleton }; diff --git a/web/app/src/components/ui/tabs.tsx b/web/app/src/components/ui/tabs.tsx deleted file mode 100644 index 7a11d54972a..00000000000 --- a/web/app/src/components/ui/tabs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react'; -import * as TabsPrimitive from '@radix-ui/react-tabs'; - -import { cn } from '@/lib/utils'; - -const Tabs = TabsPrimitive.Root; - -const TabsList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsList.displayName = TabsPrimitive.List.displayName; - -const TabsTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; - -const TabsContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsContent.displayName = TabsPrimitive.Content.displayName; - -export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/web/app/src/components/ui/tooltip.tsx b/web/app/src/components/ui/tooltip.tsx deleted file mode 100644 index 6e626f07692..00000000000 --- a/web/app/src/components/ui/tooltip.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import * as TooltipPrimitive from '@radix-ui/react-tooltip'; - -import { cn } from '@/lib/utils'; - -const TooltipProvider = TooltipPrimitive.Provider; - -const Tooltip = TooltipPrimitive.Root; - -const TooltipTrigger = TooltipPrimitive.Trigger; - -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/web/app/src/hooks/useScores.ts b/web/app/src/hooks/useScores.ts deleted file mode 100644 index 1a46fc7dac1..00000000000 --- a/web/app/src/hooks/useScores.ts +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { getScores } from '@/lib/api'; -import { useAsyncResource } from '@/hooks/useAsyncResource'; -import type { Scores } from '@/types/scores'; - -export interface UseScoresReturn { - scores: Scores | null; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useScores(): UseScoresReturn { - const { data, loading, error, refresh } = useAsyncResource('scores', getScores, { - fallbackMessage: 'Failed to load scores', - }); - - return { scores: data ?? null, loading, error, refresh }; -} From 99fc6835a80cc312e464e12774d1f524fa334f79 Mon Sep 17 00:00:00 2001 From: yny4ik Date: Thu, 27 Aug 2026 17:19:09 +0300 Subject: [PATCH 2/7] refactor(web-app): colocate product code in domain features Move destinations into src/features//{api,model,ui}, split HTTP off lib/api.ts onto a shared fetch client, and guard public-index imports so callers cannot reach deep UI files. INV-MEM-1: memories path globs follow the new feature folder; tier behavior is unchanged. Co-authored-by: Cursor --- .github/checks-manifest.yaml | 2 +- docs/agents/web-app-destinations.md | 2 +- docs/agents/web-app-signals.md | 2 +- docs/product/invariants/memory-tiers.md | 4 +- web/app/AGENTS.md | 11 +- web/app/ARCHITECTURE.md | 36 + web/app/package.json | 5 +- web/app/scripts/check-feature-imports.test.ts | 16 + web/app/scripts/check-feature-imports.ts | 110 + web/app/scripts/copy-moonshine-assets.ts | 2 +- .../connectors/[id]/edit/page.tsx | 4 +- .../(authenticated)/connectors/[id]/page.tsx | 2 +- .../(authenticated)/connectors/new/page.tsx | 2 +- .../app/(authenticated)/connectors/page.tsx | 2 +- .../(authenticated)/conversations/loading.tsx | 4 +- .../(authenticated)/conversations/page.tsx | 2 +- .../src/app/(authenticated)/fair-use/page.tsx | 2 +- web/app/src/app/(authenticated)/help/page.tsx | 2 +- web/app/src/app/(authenticated)/home/page.tsx | 2 +- web/app/src/app/(authenticated)/layout.tsx | 2 +- .../src/app/(authenticated)/memories/page.tsx | 2 +- .../src/app/(authenticated)/settings/page.tsx | 2 +- .../src/app/(authenticated)/tasks/page.tsx | 2 +- web/app/src/app/(public)/apps/[id]/page.tsx | 4 +- .../apps/__tests__/marketplacePage.test.tsx | 4 +- .../apps/category/[category]/page.tsx | 12 +- web/app/src/app/(public)/apps/page.tsx | 4 +- web/app/src/app/(public)/layout.tsx | 14 +- web/app/src/app/case/[ref]/page.tsx | 2 +- .../case/__tests__/caseStatusOrigin.test.tsx | 2 +- web/app/src/app/layout.tsx | 4 +- web/app/src/app/login/LoginClient.tsx | 2 +- .../app/login/__tests__/LoginClient.test.ts | 2 +- web/app/src/app/record/page.tsx | 10 +- web/app/src/app/record/popout/page.tsx | 2 +- .../src/app/record/popout/transcript/page.tsx | 6 +- web/app/src/app/sitemap.ts | 2 +- .../components/layout/BottomNavigation.tsx | 6 +- web/app/src/components/layout/MainLayout.tsx | 19 +- .../components/layout/MarketplaceHeader.tsx | 2 +- web/app/src/components/layout/Sidebar.tsx | 6 +- .../layout/__tests__/Sidebar.test.tsx | 4 +- web/app/src/components/recording/index.ts | 9 - .../src/components/settings/SettingsPage.tsx | 3571 ----------------- web/app/src/features/auth/index.ts | 3 + .../auth/ui}/AuthProvider.tsx | 0 .../auth => features/auth/ui}/LoginPanel.tsx | 0 .../auth/ui}/ProtectedRoute.tsx | 0 web/app/src/features/chat/ARCHITECTURE.md | 8 + .../__tests__/chatSessionRouting.test.ts | 2 +- .../chat/__tests__/parseStreamLine.test.ts | 14 + .../chat}/__tests__/useChat.test.tsx | 6 +- .../chat}/__tests__/useGeminiLive.test.tsx | 4 +- web/app/src/features/chat/api.ts | 387 ++ web/app/src/features/chat/index.ts | 9 + web/app/src/features/chat/model.ts | 79 + .../chat => features/chat/ui}/ChatBubble.tsx | 0 .../chat/ui}/ChatComposer.tsx | 2 +- .../chat => features/chat/ui}/ChatContext.tsx | 2 +- .../chat/ui}/ChatMarkdown.tsx | 0 .../chat => features/chat/ui}/ChatPanel.tsx | 5 +- .../chat/ui}/ChatTranscript.tsx | 0 .../chat => features/chat/ui}/FilePreview.tsx | 0 .../chat/ui}/RecordingStage.tsx | 2 +- .../chat/ui}/VoiceRecorder.tsx | 2 +- .../chat/ui}/__tests__/ChatComposer.test.tsx | 6 +- .../chat/ui}/__tests__/ChatMarkdown.test.ts | 2 +- .../ui}/__tests__/RecordingStage.test.tsx | 4 +- .../__tests__/chatSessionSelection.test.tsx | 8 +- .../src/{hooks => features/chat}/useChat.ts | 9 +- .../{hooks => features/chat}/useGeminiLive.ts | 4 +- .../src/features/connectors/ARCHITECTURE.md | 7 + .../connectors}/__tests__/connectors.test.ts | 2 +- web/app/src/features/connectors/api.ts | 527 +++ web/app/src/features/connectors/index.ts | 7 + .../connectors/model.ts} | 0 .../connectors/ui}/AppCard.tsx | 2 +- .../connectors/ui}/AppDetail.tsx | 6 +- .../connectors/ui}/AppDisabledNotice.tsx | 0 .../connectors/ui}/AppForm.tsx | 12 +- .../connectors/ui}/AppGridSection.tsx | 0 .../connectors/ui}/AppsExplorer.tsx | 10 +- .../connectors/ui}/ConnectedServices.tsx | 8 +- .../ui}/__tests__/ConnectedServices.test.tsx | 4 +- .../features/conversations/ARCHITECTURE.md | 11 + .../__tests__/conversationPanelSizing.test.ts | 2 +- .../__tests__/conversationTimeline.test.ts | 13 +- web/app/src/features/conversations/api.ts | 615 +++ web/app/src/features/conversations/index.ts | 11 + .../conversations/model.ts} | 50 + .../recaps/ConversationPreviewPanel.tsx | 2 +- .../recaps/RecapDetailPanel.tsx | 2 +- .../recaps/sections/HighlightsSection.tsx | 0 .../recaps/sections/InsightsSection.tsx | 0 .../recaps/sections/LocationMap.tsx | 2 +- .../recaps/sections/LocationsSection.tsx | 0 .../recaps/sections/TasksSection.tsx | 0 .../conversations/ui/ActionItemsTab.tsx | 85 + .../conversations/ui}/AppSummaryCard.tsx | 2 +- .../conversations/ui}/AudioPlayer.tsx | 2 +- .../ui}/ConversationActionsMenu.tsx | 2 +- .../ui}/ConversationDetailPanel.tsx | 357 +- .../conversations/ui}/ConversationGallery.tsx | 2 +- .../ui}/ConversationSplitView.tsx | 56 +- .../conversations/ui}/ConversationTile.tsx | 2 +- .../conversations/ui}/DateFilter.tsx | 0 .../ui}/DeleteConversationsDialog.tsx | 0 .../conversations/ui/DetailSkeleton.tsx | 36 + .../conversations/ui}/EditableTitle.tsx | 2 +- .../conversations/ui}/FolderDialog.tsx | 0 .../conversations/ui}/FolderTabs.tsx | 0 .../ui}/GenerateSummaryButton.tsx | 11 +- .../conversations/ui}/ManagePeopleModal.tsx | 2 +- .../conversations/ui}/MergeActionBar.tsx | 0 .../ui}/MergeConfirmationDialog.tsx | 0 .../conversations/ui}/MoveFolderDialog.tsx | 0 .../conversations/ui}/PersonChip.tsx | 0 .../conversations/ui}/RecapTile.tsx | 0 .../conversations/ui}/SpeakerTagSheet.tsx | 4 +- .../features/conversations/ui/SummaryTab.tsx | 199 + .../conversations/ui}/TranscriptView.tsx | 0 .../__tests__/ConversationGallery.test.tsx | 4 +- .../__tests__/ConversationSplitView.test.tsx | 36 +- .../ui}/__tests__/RecapTile.test.tsx | 2 +- .../conversations}/useConversation.ts | 2 +- .../conversations}/useConversations.ts | 2 +- .../conversations}/usePeople.ts | 2 +- .../conversations}/useRecaps.ts | 7 +- .../conversations}/useSearchConversations.ts | 2 +- web/app/src/features/fair-use/api.ts | 29 + web/app/src/features/fair-use/index.ts | 3 + .../fair-use/ui}/CaseStatusView.tsx | 0 .../fair-use/ui}/FairUseStatus.tsx | 4 +- .../goals}/__tests__/goalVisuals.test.ts | 4 +- .../goals}/__tests__/goals.test.ts | 2 +- .../goals}/__tests__/goalsMetricValue.test.ts | 2 +- .../goals}/__tests__/useGoalDetail.test.tsx | 6 +- .../goals}/__tests__/useGoals.test.tsx | 6 +- web/app/src/features/goals/api.ts | 110 + .../src/{lib => features/goals}/goalEmoji.ts | 0 .../{lib => features/goals}/goalVisuals.ts | 0 web/app/src/{lib => features/goals}/goals.ts | 6 +- web/app/src/features/goals/index.ts | 6 + .../goals}/useGoalDetail.ts | 2 +- .../src/{hooks => features/goals}/useGoals.ts | 4 +- web/app/src/features/home/index.ts | 1 + .../home => features/home/ui}/GoalCard.tsx | 4 +- .../home/ui}/GoalComposer.tsx | 2 +- .../home/ui}/GoalDetailSheet.tsx | 6 +- .../home/ui}/GoalSparkline.tsx | 2 +- .../home => features/home/ui}/HomePage.tsx | 16 +- .../home/ui}/HomeTaskList.tsx | 0 .../home/ui}/__tests__/GoalCard.test.tsx | 2 +- .../home/ui}/__tests__/GoalComposer.test.tsx | 2 +- .../ui}/__tests__/GoalDetailSheet.test.tsx | 6 +- .../home/ui}/__tests__/HomePage.test.tsx | 70 +- .../src/features/marketplace/ARCHITECTURE.md | 6 + web/app/src/features/marketplace/index.ts | 9 + .../marketplace/ui}/AppList.tsx | 0 .../marketplace/ui}/CategoryBreadcrumb.tsx | 0 .../marketplace/ui}/CategoryHeader.tsx | 0 .../marketplace/ui}/DeveloperBanner.tsx | 0 .../marketplace/ui}/NewBadge.tsx | 0 .../marketplace/ui}/PromoCard.tsx | 2 +- .../marketplace/ui}/ScrollableCategoryNav.tsx | 0 .../marketplace/ui}/SearchBar.tsx | 0 .../marketplace/ui}/category.ts | 0 .../ui}/plugin-card/CompactPluginCard.tsx | 0 .../ui}/plugin-card/FeaturedPluginCard.tsx | 0 .../marketplace/ui}/types.ts | 0 .../marketplace/ui}/utils/format.ts | 0 web/app/src/features/memories/ARCHITECTURE.md | 8 + .../__tests__/memoryCategory.test.ts | 2 +- web/app/src/features/memories/api.ts | 150 + web/app/src/features/memories/index.ts | 6 + .../memories}/insights.worker.ts | 0 .../memories}/memoryCategory.ts | 0 .../memories}/memoryExport.ts | 0 .../memories/ui}/InsightsDashboard.tsx | 2 +- .../memories/ui}/KnowledgeGraph.tsx | 2 +- .../memories/ui}/MemoriesPage.tsx | 12 +- .../memories/ui}/MemoriesPrefetcher.tsx | 4 +- .../memories/ui}/MemoryCard.tsx | 0 .../memories/ui}/MemoryFilters.tsx | 0 .../memories/ui}/MemoryList.tsx | 0 .../memories/ui}/MemoryQuickAdd.tsx | 0 .../ui}/__tests__/MemoriesPage.test.tsx | 12 +- .../ui}/__tests__/MemoryCard.test.tsx | 2 +- .../ui}/__tests__/MemoryList.test.tsx | 2 +- .../memories}/useInsightsDashboard.ts | 2 +- .../memories}/useKnowledgeGraph.ts | 2 +- .../memories}/useMemories.ts | 10 +- web/app/src/features/notifications/api.ts | 57 + web/app/src/features/notifications/index.ts | 4 + .../notifications/ui}/NotificationCenter.tsx | 0 .../notifications/ui}/NotificationContext.tsx | 4 +- .../notifications/ui}/NotificationItem.tsx | 0 .../ui}/NotificationPermissionBanner.tsx | 0 .../notifications}/useNotifications.ts | 2 +- .../src/features/recording/ARCHITECTURE.md | 6 + web/app/src/features/recording/index.ts | 9 + .../recording/ui}/AudioModeSelector.tsx | 0 .../ui}/HeaderRecordingIndicator.tsx | 4 +- .../recording/ui}/LiveTranscript.tsx | 0 .../recording/ui}/RecordingContext.tsx | 0 .../recording/ui}/RecordingController.tsx | 2 +- .../recording/ui}/RecordingControls.tsx | 0 .../recording/ui}/RecordingWidget.tsx | 2 +- .../HeaderRecordingIndicator.test.ts | 0 .../__tests__/RecordingController.test.tsx | 8 +- .../recording}/useRecording.ts | 5 +- web/app/src/features/settings/ARCHITECTURE.md | 10 + .../__tests__/settingsSections.test.ts | 39 +- web/app/src/features/settings/api.ts | 453 +++ web/app/src/features/settings/index.ts | 14 + .../settings/model.ts} | 31 + .../features/settings/ui/AccountSection.tsx | 116 + .../features/settings/ui/DeveloperSection.tsx | 1294 ++++++ .../features/settings/ui/PrivacySection.tsx | 60 + .../features/settings/ui/ProfileSection.tsx | 225 ++ .../src/features/settings/ui/SettingsPage.tsx | 577 +++ .../src/features/settings/ui/UsageSection.tsx | 1021 +++++ .../settings/ui/settingsPrimitives.tsx | 274 ++ web/app/src/features/tasks/ARCHITECTURE.md | 8 + .../tasks/__tests__/groupActionItems.test.ts | 40 + .../tasks}/__tests__/taskDue.test.ts | 2 +- .../tasks}/__tests__/taskExport.test.ts | 2 +- .../tasks}/__tests__/useHomeTasks.test.tsx | 6 +- web/app/src/features/tasks/api.ts | 113 + web/app/src/features/tasks/index.ts | 7 + web/app/src/features/tasks/model.ts | 221 + .../src/{lib => features/tasks}/taskExport.ts | 0 .../tasks/ui}/BulkActionBar.tsx | 0 .../tasks/ui}/MonthCalendar.tsx | 0 .../tasks/ui}/NoDueDatePrompt.tsx | 0 .../tasks => features/tasks/ui}/TaskCard.tsx | 2 +- .../tasks => features/tasks/ui}/TaskGroup.tsx | 0 .../tasks => features/tasks/ui}/TaskHub.tsx | 8 +- .../tasks/ui}/TaskListView.tsx | 0 .../tasks/ui}/TaskProgressCard.tsx | 0 .../tasks/ui}/TaskQuickAdd.tsx | 0 .../tasks/ui}/TaskRightPanel.tsx | 0 .../tasks => features/tasks/ui}/TaskRow.tsx | 2 +- .../tasks}/useActionItems.ts | 168 +- .../{hooks => features/tasks}/useHomeTasks.ts | 2 +- .../tasks}/useTaskKeyboardShortcuts.ts | 0 web/app/src/lib/__tests__/profileMenu.test.ts | 2 +- .../lib/__tests__/subscriptionPlanApi.test.ts | 27 +- web/app/src/lib/api.ts | 2600 ------------ web/app/src/lib/conversationPanelSizing.ts | 17 - web/app/src/lib/geminiLive.ts | 2 +- web/app/src/lib/recordingBroadcast.ts | 17 +- web/app/src/lib/taskDue.ts | 79 - web/app/src/shared/api/client.ts | 99 + web/app/src/types/chatSessions.ts | 2 +- web/app/vitest.setup.ts | 24 + 256 files changed, 7591 insertions(+), 7181 deletions(-) create mode 100644 web/app/ARCHITECTURE.md create mode 100644 web/app/scripts/check-feature-imports.test.ts create mode 100644 web/app/scripts/check-feature-imports.ts delete mode 100644 web/app/src/components/recording/index.ts delete mode 100644 web/app/src/components/settings/SettingsPage.tsx create mode 100644 web/app/src/features/auth/index.ts rename web/app/src/{components/auth => features/auth/ui}/AuthProvider.tsx (100%) rename web/app/src/{components/auth => features/auth/ui}/LoginPanel.tsx (100%) rename web/app/src/{components/auth => features/auth/ui}/ProtectedRoute.tsx (100%) create mode 100644 web/app/src/features/chat/ARCHITECTURE.md rename web/app/src/{lib => features/chat}/__tests__/chatSessionRouting.test.ts (99%) create mode 100644 web/app/src/features/chat/__tests__/parseStreamLine.test.ts rename web/app/src/{hooks => features/chat}/__tests__/useChat.test.tsx (97%) rename web/app/src/{hooks => features/chat}/__tests__/useGeminiLive.test.tsx (94%) create mode 100644 web/app/src/features/chat/api.ts create mode 100644 web/app/src/features/chat/index.ts create mode 100644 web/app/src/features/chat/model.ts rename web/app/src/{components/chat => features/chat/ui}/ChatBubble.tsx (100%) rename web/app/src/{components/chat => features/chat/ui}/ChatComposer.tsx (99%) rename web/app/src/{components/chat => features/chat/ui}/ChatContext.tsx (98%) rename web/app/src/{components/chat => features/chat/ui}/ChatMarkdown.tsx (100%) rename web/app/src/{components/chat => features/chat/ui}/ChatPanel.tsx (99%) rename web/app/src/{components/chat => features/chat/ui}/ChatTranscript.tsx (100%) rename web/app/src/{components/chat => features/chat/ui}/FilePreview.tsx (100%) rename web/app/src/{components/chat => features/chat/ui}/RecordingStage.tsx (98%) rename web/app/src/{components/chat => features/chat/ui}/VoiceRecorder.tsx (99%) rename web/app/src/{components/chat => features/chat/ui}/__tests__/ChatComposer.test.tsx (97%) rename web/app/src/{components/chat => features/chat/ui}/__tests__/ChatMarkdown.test.ts (92%) rename web/app/src/{components/chat => features/chat/ui}/__tests__/RecordingStage.test.tsx (97%) rename web/app/src/{components/chat => features/chat/ui}/__tests__/chatSessionSelection.test.tsx (91%) rename web/app/src/{hooks => features/chat}/useChat.ts (97%) rename web/app/src/{hooks => features/chat}/useGeminiLive.ts (97%) create mode 100644 web/app/src/features/connectors/ARCHITECTURE.md rename web/app/src/{lib => features/connectors}/__tests__/connectors.test.ts (93%) create mode 100644 web/app/src/features/connectors/api.ts create mode 100644 web/app/src/features/connectors/index.ts rename web/app/src/{lib/connectors.ts => features/connectors/model.ts} (100%) rename web/app/src/{components/apps => features/connectors/ui}/AppCard.tsx (98%) rename web/app/src/{components/apps => features/connectors/ui}/AppDetail.tsx (98%) rename web/app/src/{components/apps => features/connectors/ui}/AppDisabledNotice.tsx (100%) rename web/app/src/{components/apps => features/connectors/ui}/AppForm.tsx (99%) rename web/app/src/{components/apps => features/connectors/ui}/AppGridSection.tsx (100%) rename web/app/src/{components/apps => features/connectors/ui}/AppsExplorer.tsx (99%) rename web/app/src/{components/apps => features/connectors/ui}/ConnectedServices.tsx (98%) rename web/app/src/{components/apps => features/connectors/ui}/__tests__/ConnectedServices.test.tsx (97%) create mode 100644 web/app/src/features/conversations/ARCHITECTURE.md rename web/app/src/{lib => features/conversations}/__tests__/conversationPanelSizing.test.ts (94%) rename web/app/src/{lib => features/conversations}/__tests__/conversationTimeline.test.ts (93%) create mode 100644 web/app/src/features/conversations/api.ts create mode 100644 web/app/src/features/conversations/index.ts rename web/app/src/{lib/conversationTimeline.ts => features/conversations/model.ts} (79%) rename web/app/src/{components => features/conversations}/recaps/ConversationPreviewPanel.tsx (99%) rename web/app/src/{components => features/conversations}/recaps/RecapDetailPanel.tsx (99%) rename web/app/src/{components => features/conversations}/recaps/sections/HighlightsSection.tsx (100%) rename web/app/src/{components => features/conversations}/recaps/sections/InsightsSection.tsx (100%) rename web/app/src/{components => features/conversations}/recaps/sections/LocationMap.tsx (99%) rename web/app/src/{components => features/conversations}/recaps/sections/LocationsSection.tsx (100%) rename web/app/src/{components => features/conversations}/recaps/sections/TasksSection.tsx (100%) create mode 100644 web/app/src/features/conversations/ui/ActionItemsTab.tsx rename web/app/src/{components/conversations => features/conversations/ui}/AppSummaryCard.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/AudioPlayer.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/ConversationActionsMenu.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/ConversationDetailPanel.tsx (69%) rename web/app/src/{components/conversations => features/conversations/ui}/ConversationGallery.tsx (98%) rename web/app/src/{components/conversations => features/conversations/ui}/ConversationSplitView.tsx (94%) rename web/app/src/{components/conversations => features/conversations/ui}/ConversationTile.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/DateFilter.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/DeleteConversationsDialog.tsx (100%) create mode 100644 web/app/src/features/conversations/ui/DetailSkeleton.tsx rename web/app/src/{components/conversations => features/conversations/ui}/EditableTitle.tsx (97%) rename web/app/src/{components/conversations => features/conversations/ui}/FolderDialog.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/FolderTabs.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/GenerateSummaryButton.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/ManagePeopleModal.tsx (99%) rename web/app/src/{components/conversations => features/conversations/ui}/MergeActionBar.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/MergeConfirmationDialog.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/MoveFolderDialog.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/PersonChip.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/RecapTile.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/SpeakerTagSheet.tsx (99%) create mode 100644 web/app/src/features/conversations/ui/SummaryTab.tsx rename web/app/src/{components/conversations => features/conversations/ui}/TranscriptView.tsx (100%) rename web/app/src/{components/conversations => features/conversations/ui}/__tests__/ConversationGallery.test.tsx (96%) rename web/app/src/{components/conversations => features/conversations/ui}/__tests__/ConversationSplitView.test.tsx (89%) rename web/app/src/{components/conversations => features/conversations/ui}/__tests__/RecapTile.test.tsx (97%) rename web/app/src/{hooks => features/conversations}/useConversation.ts (96%) rename web/app/src/{hooks => features/conversations}/useConversations.ts (98%) rename web/app/src/{hooks => features/conversations}/usePeople.ts (98%) rename web/app/src/{hooks => features/conversations}/useRecaps.ts (98%) rename web/app/src/{hooks => features/conversations}/useSearchConversations.ts (98%) create mode 100644 web/app/src/features/fair-use/api.ts create mode 100644 web/app/src/features/fair-use/index.ts rename web/app/src/{components/fair-use => features/fair-use/ui}/CaseStatusView.tsx (100%) rename web/app/src/{components/fair-use => features/fair-use/ui}/FairUseStatus.tsx (98%) rename web/app/src/{lib => features/goals}/__tests__/goalVisuals.test.ts (96%) rename web/app/src/{lib => features/goals}/__tests__/goals.test.ts (99%) rename web/app/src/{lib => features/goals}/__tests__/goalsMetricValue.test.ts (93%) rename web/app/src/{hooks => features/goals}/__tests__/useGoalDetail.test.tsx (86%) rename web/app/src/{hooks => features/goals}/__tests__/useGoals.test.tsx (98%) create mode 100644 web/app/src/features/goals/api.ts rename web/app/src/{lib => features/goals}/goalEmoji.ts (100%) rename web/app/src/{lib => features/goals}/goalVisuals.ts (100%) rename web/app/src/{lib => features/goals}/goals.ts (93%) create mode 100644 web/app/src/features/goals/index.ts rename web/app/src/{hooks => features/goals}/useGoalDetail.ts (97%) rename web/app/src/{hooks => features/goals}/useGoals.ts (98%) create mode 100644 web/app/src/features/home/index.ts rename web/app/src/{components/home => features/home/ui}/GoalCard.tsx (98%) rename web/app/src/{components/home => features/home/ui}/GoalComposer.tsx (98%) rename web/app/src/{components/home => features/home/ui}/GoalDetailSheet.tsx (98%) rename web/app/src/{components/home => features/home/ui}/GoalSparkline.tsx (95%) rename web/app/src/{components/home => features/home/ui}/HomePage.tsx (96%) rename web/app/src/{components/home => features/home/ui}/HomeTaskList.tsx (100%) rename web/app/src/{components/home => features/home/ui}/__tests__/GoalCard.test.tsx (99%) rename web/app/src/{components/home => features/home/ui}/__tests__/GoalComposer.test.tsx (98%) rename web/app/src/{components/home => features/home/ui}/__tests__/GoalDetailSheet.test.tsx (97%) rename web/app/src/{components/home => features/home/ui}/__tests__/HomePage.test.tsx (86%) create mode 100644 web/app/src/features/marketplace/ARCHITECTURE.md create mode 100644 web/app/src/features/marketplace/index.ts rename web/app/src/{components/marketplace => features/marketplace/ui}/AppList.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/CategoryBreadcrumb.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/CategoryHeader.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/DeveloperBanner.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/NewBadge.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/PromoCard.tsx (98%) rename web/app/src/{components/marketplace => features/marketplace/ui}/ScrollableCategoryNav.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/SearchBar.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/category.ts (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/plugin-card/CompactPluginCard.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/plugin-card/FeaturedPluginCard.tsx (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/types.ts (100%) rename web/app/src/{components/marketplace => features/marketplace/ui}/utils/format.ts (100%) create mode 100644 web/app/src/features/memories/ARCHITECTURE.md rename web/app/src/{lib => features/memories}/__tests__/memoryCategory.test.ts (96%) create mode 100644 web/app/src/features/memories/api.ts create mode 100644 web/app/src/features/memories/index.ts rename web/app/src/{workers => features/memories}/insights.worker.ts (100%) rename web/app/src/{lib => features/memories}/memoryCategory.ts (100%) rename web/app/src/{lib => features/memories}/memoryExport.ts (100%) rename web/app/src/{components/memories => features/memories/ui}/InsightsDashboard.tsx (99%) rename web/app/src/{components/memories => features/memories/ui}/KnowledgeGraph.tsx (99%) rename web/app/src/{components/memories => features/memories/ui}/MemoriesPage.tsx (98%) rename web/app/src/{components/memories => features/memories/ui}/MemoriesPrefetcher.tsx (94%) rename web/app/src/{components/memories => features/memories/ui}/MemoryCard.tsx (100%) rename web/app/src/{components/memories => features/memories/ui}/MemoryFilters.tsx (100%) rename web/app/src/{components/memories => features/memories/ui}/MemoryList.tsx (100%) rename web/app/src/{components/memories => features/memories/ui}/MemoryQuickAdd.tsx (100%) rename web/app/src/{components/memories => features/memories/ui}/__tests__/MemoriesPage.test.tsx (92%) rename web/app/src/{components/memories => features/memories/ui}/__tests__/MemoryCard.test.tsx (97%) rename web/app/src/{components/memories => features/memories/ui}/__tests__/MemoryList.test.tsx (96%) rename web/app/src/{hooks => features/memories}/useInsightsDashboard.ts (98%) rename web/app/src/{hooks => features/memories}/useKnowledgeGraph.ts (98%) rename web/app/src/{hooks => features/memories}/useMemories.ts (99%) create mode 100644 web/app/src/features/notifications/api.ts create mode 100644 web/app/src/features/notifications/index.ts rename web/app/src/{components/notifications => features/notifications/ui}/NotificationCenter.tsx (100%) rename web/app/src/{components/notifications => features/notifications/ui}/NotificationContext.tsx (94%) rename web/app/src/{components/notifications => features/notifications/ui}/NotificationItem.tsx (100%) rename web/app/src/{components/notifications => features/notifications/ui}/NotificationPermissionBanner.tsx (100%) rename web/app/src/{hooks => features/notifications}/useNotifications.ts (99%) create mode 100644 web/app/src/features/recording/ARCHITECTURE.md create mode 100644 web/app/src/features/recording/index.ts rename web/app/src/{components/recording => features/recording/ui}/AudioModeSelector.tsx (100%) rename web/app/src/{components/recording => features/recording/ui}/HeaderRecordingIndicator.tsx (99%) rename web/app/src/{components/recording => features/recording/ui}/LiveTranscript.tsx (100%) rename web/app/src/{components/recording => features/recording/ui}/RecordingContext.tsx (100%) rename web/app/src/{components/recording => features/recording/ui}/RecordingController.tsx (97%) rename web/app/src/{components/recording => features/recording/ui}/RecordingControls.tsx (100%) rename web/app/src/{components/recording => features/recording/ui}/RecordingWidget.tsx (99%) rename web/app/src/{components/recording => features/recording/ui}/__tests__/HeaderRecordingIndicator.test.ts (100%) rename web/app/src/{components/recording => features/recording/ui}/__tests__/RecordingController.test.tsx (93%) rename web/app/src/{hooks => features/recording}/useRecording.ts (98%) create mode 100644 web/app/src/features/settings/ARCHITECTURE.md rename web/app/src/{lib => features/settings}/__tests__/settingsSections.test.ts (69%) create mode 100644 web/app/src/features/settings/api.ts create mode 100644 web/app/src/features/settings/index.ts rename web/app/src/{lib/settingsSections.ts => features/settings/model.ts} (59%) create mode 100644 web/app/src/features/settings/ui/AccountSection.tsx create mode 100644 web/app/src/features/settings/ui/DeveloperSection.tsx create mode 100644 web/app/src/features/settings/ui/PrivacySection.tsx create mode 100644 web/app/src/features/settings/ui/ProfileSection.tsx create mode 100644 web/app/src/features/settings/ui/SettingsPage.tsx create mode 100644 web/app/src/features/settings/ui/UsageSection.tsx create mode 100644 web/app/src/features/settings/ui/settingsPrimitives.tsx create mode 100644 web/app/src/features/tasks/ARCHITECTURE.md create mode 100644 web/app/src/features/tasks/__tests__/groupActionItems.test.ts rename web/app/src/{lib => features/tasks}/__tests__/taskDue.test.ts (99%) rename web/app/src/{lib => features/tasks}/__tests__/taskExport.test.ts (98%) rename web/app/src/{hooks => features/tasks}/__tests__/useHomeTasks.test.tsx (88%) create mode 100644 web/app/src/features/tasks/api.ts create mode 100644 web/app/src/features/tasks/index.ts create mode 100644 web/app/src/features/tasks/model.ts rename web/app/src/{lib => features/tasks}/taskExport.ts (100%) rename web/app/src/{components/tasks => features/tasks/ui}/BulkActionBar.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/MonthCalendar.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/NoDueDatePrompt.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskCard.tsx (99%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskGroup.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskHub.tsx (98%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskListView.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskProgressCard.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskQuickAdd.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskRightPanel.tsx (100%) rename web/app/src/{components/tasks => features/tasks/ui}/TaskRow.tsx (99%) rename web/app/src/{hooks => features/tasks}/useActionItems.ts (76%) rename web/app/src/{hooks => features/tasks}/useHomeTasks.ts (95%) rename web/app/src/{hooks => features/tasks}/useTaskKeyboardShortcuts.ts (100%) delete mode 100644 web/app/src/lib/api.ts delete mode 100644 web/app/src/lib/conversationPanelSizing.ts delete mode 100644 web/app/src/lib/taskDue.ts create mode 100644 web/app/src/shared/api/client.ts diff --git a/.github/checks-manifest.yaml b/.github/checks-manifest.yaml index a74ab79ae46..47054a279cc 100644 --- a/.github/checks-manifest.yaml +++ b/.github/checks-manifest.yaml @@ -351,7 +351,7 @@ checks: reason: "public-build contract and GitHub variable scope fixtures must remain executable" - id: web-app-checks command: ["bash", "web/app/test.sh"] - triggers: ["web/app/src/**", "web/app/package.json", "web/app/bun.lock", "web/app/tsconfig.json", "web/app/vitest.config.mts", "web/app/vitest.setup.ts", "web/app/scripts/moonshine-smoke.test.ts", "web/app/test.sh", ".github/checks-manifest.yaml"] + triggers: ["web/app/src/**", "web/app/package.json", "web/app/bun.lock", "web/app/tsconfig.json", "web/app/vitest.config.mts", "web/app/vitest.setup.ts", "web/app/tailwind.config.ts", "web/app/scripts/moonshine-smoke.test.ts", "web/app/scripts/check-feature-imports.ts", "web/app/scripts/check-feature-imports.test.ts", "web/app/test.sh", "web/app/ARCHITECTURE.md", "web/app/AGENTS.md", ".github/checks-manifest.yaml"] lanes: ["local", "ci"] reason: "web/app shipped with no typecheck or test lane at all, so desktop-parity work there had no runnable Definition-of-Done gate" - id: admin-deploy-scope diff --git a/docs/agents/web-app-destinations.md b/docs/agents/web-app-destinations.md index 9f3b2e3463c..13126fe8b3a 100644 --- a/docs/agents/web-app-destinations.md +++ b/docs/agents/web-app-destinations.md @@ -7,7 +7,7 @@ page, moving a surface between pages, or debugging an animation that looks stuck | Route | Holds | |---|---| -| `/home` | The hub **and** the chat. Chat is not a separate page: `web/app/src/components/home/HomePage.tsx` keeps recent history above the hub and the current exchange below it, with transcript rendering owned by `web/app/src/components/chat/ChatTranscript.tsx`. Live capture also starts here, from the composer. | +| `/home` | The hub **and** the chat. Chat is not a separate page: `web/app/src/features/home/ui/HomePage.tsx` keeps recent history above the hub and the current exchange below it, with transcript rendering owned by `web/app/src/features/chat/ui/ChatTranscript.tsx`. Live capture also starts here, from the composer. | | `/conversations` | Conversations and daily recaps in one day-grouped gallery. A recap is the summary of a day, so it leads that day rather than living in a list of its own. | | `/memories`, `/tasks` | As named. | | `/connectors` | Installed apps **and** external services — the former Settings → Integrations. | diff --git a/docs/agents/web-app-signals.md b/docs/agents/web-app-signals.md index 6368dddb83e..1802add1622 100644 --- a/docs/agents/web-app-signals.md +++ b/docs/agents/web-app-signals.md @@ -9,7 +9,7 @@ so the next person does not reintroduce the pattern it replaced. | Need | Use | |---|---| | Read something from the API | `useAsyncResource(key, fetcher)` (`web/app/src/hooks/useAsyncResource.ts`) | -| A list that is also written optimistically | A signal store — see `createGoalsStore` in `web/app/src/hooks/useGoals.ts` | +| A list that is also written optimistically | A signal store — see `createGoalsStore` in `web/app/src/features/goals/useGoals.ts` | | Subscribe a component to a raw signal | `useSignalValue` / `useResourceValue` (`web/app/src/lib/signals.ts`) | `useAsyncResource` is keyed: change the key and it refetches, pass `null` and it diff --git a/docs/product/invariants/memory-tiers.md b/docs/product/invariants/memory-tiers.md index 140d459fe00..52db9bb039e 100644 --- a/docs/product/invariants/memory-tiers.md +++ b/docs/product/invariants/memory-tiers.md @@ -46,8 +46,8 @@ default access policy of Short-term + Long-term. - `desktop/macos/Desktop/Sources/MemoryBankConnector.swift` - `desktop/macos/Desktop/Sources/Rewind/Core/MemoryStorage.swift` - `desktop/macos/Desktop/Sources/Rewind/Core/MemoryModels.swift` -- `web/app/src/components/memories/**` -- `web/app/src/lib/memoryExport.ts` +- `web/app/src/features/memories/**` +- `web/app/src/features/memories/memoryExport.ts` - `web/frontend/src/components/memories/**` ## PR rule diff --git a/web/app/AGENTS.md b/web/app/AGENTS.md index b4bc67b4ef2..3d12cf29afb 100644 --- a/web/app/AGENTS.md +++ b/web/app/AGENTS.md @@ -3,6 +3,15 @@ The signed-in Omi web client: React 19 on the `@tschk/moonshine` runtime, served by Bun. Siblings: `web/admin`, `web/frontend`, `web/personas-open-source`. +Code layout: domain modules under `src/features//{api,model,ui}` plus +`src/shared` (HTTP kernel). Map and import rules: +[`ARCHITECTURE.md`](./ARCHITECTURE.md). Guard: `bun run check:imports`. +Outside a feature import only its `index.ts`, `api.ts`, or `model.ts`. + +Current domains: `auth`, `chat`, `connectors`, `conversations` (includes recaps), +`fair-use`, `goals`, `home`, `marketplace`, `memories` (**INV-MEM-1**), +`notifications`, `recording`, `settings`, `tasks`. + ## Setup ```bash @@ -44,7 +53,7 @@ reads, a signal store for optimistically-written lists. Import only the kernel why and the pitfalls: [`docs/agents/web-app-signals.md`](../../docs/agents/web-app-signals.md). Tests live in `__tests__/` beside the code (`vitest.config.mts`, jsdom, `@` → -`src/`). Prefer pure logic in `src/lib/` and `src/hooks/`; reserve component +`src/`). Prefer pure logic in `src/features//model.ts`; reserve component rendering for behavior that only appears in the tree. ## Parity With Desktop diff --git a/web/app/ARCHITECTURE.md b/web/app/ARCHITECTURE.md new file mode 100644 index 00000000000..4fc7732b1cd --- /dev/null +++ b/web/app/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# web/app architecture + +Signed-in Omi web client: React 19 on `@tschk/moonshine`. Destinations live in +`src/app`; product code lives in `src/features/` and shared kernel in +`src/shared`. + +## Layers (inside a feature) + +| Path | Holds | Must not | +|---|---|---| +| `model.ts` (and other pure helpers) | grouping, ids, parsing | React, `fetch`, `./api` | +| `api.ts` | HTTP for this domain | UI | +| `ui/` | components | ad-hoc `fetch`; thick helpers | +| hooks (`useX.ts`) | current React state / orchestration | a second data-layer | + +Cross-feature and `src/app` imports use the feature's public `index.ts` only. +`src/shared` does not import `src/features`. Enforced by +`scripts/check-feature-imports.ts` (part of `bun run check`). + +## Kernel vs features + +- `src/shared/api/client.ts` — `fetchWithAuth` and authorized blob/audio headers +- `src/lib` — firebase, cache, utils, generated OpenAPI types (not yet moved) +- `src/components/ui` and `src/components/layout` — shared chrome (not yet moved) +- Tailwind `content` is `src/**` so feature class names reach `styles.css` +- `src/hooks/useAsyncResource.ts`, `useLocalStorage`, `useRequestOwner`, + `useScrollEdges` — generic hooks, not a domain + +Writable-list moonshine stores (`createGoalsStore`) stay the pattern to copy +later; this tree does not migrate remaining `useState` lists in the same pass. + +## Features (grow as domains move) + +See `src/features/*/ARCHITECTURE.md` once a folder exceeds twelve source files. +The destination map is unchanged: `/home` is hub+chat, `/conversations` includes +recaps, `/connectors` is apps+services. diff --git a/web/app/package.json b/web/app/package.json index eaddd44c5f0..97a5ce6474c 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -10,9 +10,10 @@ "start": "bun .moonshine/server.ts", "lint": "bunx oxlint src scripts moonshine.config.ts", "typecheck": "bunx tsc --noEmit", - "test": "bun test scripts/moonshine-smoke.test.ts && bunx vitest run --config vitest.config.mts", + "test": "bun test scripts/moonshine-smoke.test.ts scripts/check-feature-imports.test.ts && bunx vitest run --config vitest.config.mts", + "check:imports": "bun scripts/check-feature-imports.ts", "test:watch": "bunx vitest", - "check": "bun run typecheck && bun run test" + "check": "bun run check:imports && bun run typecheck && bun run test" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.2", diff --git a/web/app/scripts/check-feature-imports.test.ts b/web/app/scripts/check-feature-imports.test.ts new file mode 100644 index 00000000000..ec302ce8ca3 --- /dev/null +++ b/web/app/scripts/check-feature-imports.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +const appRoot = join(import.meta.dir, '..'); + +describe('check-feature-imports', () => { + it('passes on the current tree', () => { + const result = spawnSync('bun', ['scripts/check-feature-imports.ts'], { + cwd: appRoot, + encoding: 'utf8', + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('ok:'); + }); +}); diff --git a/web/app/scripts/check-feature-imports.ts b/web/app/scripts/check-feature-imports.ts new file mode 100644 index 00000000000..fc7ac1cf78f --- /dev/null +++ b/web/app/scripts/check-feature-imports.ts @@ -0,0 +1,110 @@ +/** + * Import boundaries for src/features and src/shared. + * + * - shared must not import features + * - a feature's model.ts must not import React or that feature's api + * - code outside a feature may only import its public root (@/features/) + */ +import { readdir, readFile } from 'node:fs/promises'; +import { extname, join, relative } from 'node:path'; + +const root = join(import.meta.dir, '..'); +const srcRoot = join(root, 'src'); + +const IMPORT_RE = + /(?:from|import)\s*\(\s*['"]([^'"]+)['"]\s*\)|(?:from|import)\s+['"]([^'"]+)['"]|export\s+\*\s+from\s+['"]([^'"]+)['"]/g; + +async function walk(dir: string): Promise { + const out: string[] = []; + for (const ent of await readdir(dir, { withFileTypes: true })) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const p = join(dir, ent.name); + if (ent.isDirectory()) out.push(...(await walk(p))); + else if (['.ts', '.tsx', '.js', '.jsx'].includes(extname(ent.name))) out.push(p); + } + return out; +} + +function featureOf(file: string): string | null { + const rel = relative(srcRoot, file).replaceAll('\\', '/'); + const m = rel.match(/^features\/([^/]+)\//); + return m ? m[1] : null; +} + +function isShared(file: string): boolean { + return relative(srcRoot, file).replaceAll('\\', '/').startsWith('shared/'); +} + +function isModel(file: string): boolean { + return /(?:^|\/)model\.ts$/.test(relative(srcRoot, file).replaceAll('\\', '/')); +} + +function specs(text: string): string[] { + const found: string[] = []; + for (const m of text.matchAll(IMPORT_RE)) { + const spec = m[1] || m[2] || m[3]; + if (spec) found.push(spec); + } + return found; +} + +const errors: string[] = []; +const files = await walk(srcRoot); + +for (const file of files) { + const rel = relative(root, file); + const text = await readFile(file, 'utf8'); + const fromFeature = featureOf(file); + const model = isModel(file); + + for (const spec of specs(text)) { + if ( + isShared(file) && + (spec.startsWith('@/features/') || spec.includes('/features/')) + ) { + errors.push(`${rel} (shared) imports ${spec}`); + } + + if ( + model && + (spec === 'react' || spec.startsWith('react/') || spec === 'react-dom') + ) { + errors.push(`${rel} (model) imports React (${spec})`); + } + if ( + model && + (spec === './api' || spec.endsWith('/api') || spec === '@/shared/api/client') + ) { + errors.push(`${rel} (model) imports API (${spec})`); + } + + const featImport = spec.match(/^@\/features\/([^/]+)(?:\/(.*))?$/); + if (featImport) { + const name = featImport[1]; + const rest = featImport[2] ?? ''; + const publicFile = + rest === '' || + rest === 'index' || + rest === 'index.ts' || + rest === 'index.tsx' || + rest === 'api' || + rest === 'api.ts' || + rest === 'model' || + rest === 'model.ts'; + if (!publicFile && fromFeature !== name) { + errors.push( + `${rel} imports @/features/${name}/${rest}; outside the feature use @/features/${name}, api, or model`, + ); + } + } + } +} + +if (errors.length) { + console.error( + 'Feature import boundary violations:\n' + errors.map((e) => ` ${e}`).join('\n'), + ); + process.exit(1); +} + +console.log(`ok: ${files.length} files, feature import boundaries hold`); diff --git a/web/app/scripts/copy-moonshine-assets.ts b/web/app/scripts/copy-moonshine-assets.ts index e367c6e78f9..e23c3dbc127 100644 --- a/web/app/scripts/copy-moonshine-assets.ts +++ b/web/app/scripts/copy-moonshine-assets.ts @@ -1,5 +1,5 @@ import { cp, mkdir } from 'node:fs/promises'; -import { categoryMetadata } from '../src/components/marketplace/category'; +import { categoryMetadata } from '../src/features/marketplace/ui/category'; export function deriveWebSocketBaseUrl( apiBaseUrl: string, diff --git a/web/app/src/app/(authenticated)/connectors/[id]/edit/page.tsx b/web/app/src/app/(authenticated)/connectors/[id]/edit/page.tsx index 2e9559e9351..9dd647dc5aa 100644 --- a/web/app/src/app/(authenticated)/connectors/[id]/edit/page.tsx +++ b/web/app/src/app/(authenticated)/connectors/[id]/edit/page.tsx @@ -3,8 +3,8 @@ import { useState, useEffect } from 'react'; import { useParams } from '@tschk/moonshine-next/navigation'; import Link from '@tschk/moonshine-next/link'; -import { AppForm } from '@/components/apps/AppForm'; -import { getApp } from '@/lib/api'; +import { AppForm } from '@/features/connectors'; +import { getApp } from '@/features/connectors'; import type { App } from '@/types/apps'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/connectors/[id]/page.tsx b/web/app/src/app/(authenticated)/connectors/[id]/page.tsx index 21601cc272b..3fc0120628b 100644 --- a/web/app/src/app/(authenticated)/connectors/[id]/page.tsx +++ b/web/app/src/app/(authenticated)/connectors/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useParams } from '@tschk/moonshine-next/navigation'; -import { AppDetail } from '@/components/apps/AppDetail'; +import { AppDetail } from '@/features/connectors'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; export default function AppDetailPage() { diff --git a/web/app/src/app/(authenticated)/connectors/new/page.tsx b/web/app/src/app/(authenticated)/connectors/new/page.tsx index 5ab15c90ec3..693f787a4ae 100644 --- a/web/app/src/app/(authenticated)/connectors/new/page.tsx +++ b/web/app/src/app/(authenticated)/connectors/new/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { AppForm } from '@/components/apps/AppForm'; +import { AppForm } from '@/features/connectors'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; export default function NewAppPage() { diff --git a/web/app/src/app/(authenticated)/connectors/page.tsx b/web/app/src/app/(authenticated)/connectors/page.tsx index c718714ce0b..9bb96859743 100644 --- a/web/app/src/app/(authenticated)/connectors/page.tsx +++ b/web/app/src/app/(authenticated)/connectors/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { AppsExplorer } from '@/components/apps/AppsExplorer'; +import { AppsExplorer } from '@/features/connectors'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/conversations/loading.tsx b/web/app/src/app/(authenticated)/conversations/loading.tsx index 2f8e6e743fd..5627f702ebc 100644 --- a/web/app/src/app/(authenticated)/conversations/loading.tsx +++ b/web/app/src/app/(authenticated)/conversations/loading.tsx @@ -1,8 +1,8 @@ 'use client'; import { GanttChartSquare } from 'lucide-react'; -import { ConversationGallerySkeleton } from '@/components/conversations/ConversationGallery'; -import { FolderTabsSkeleton } from '@/components/conversations/FolderTabs'; +import { ConversationGallerySkeleton } from '@/features/conversations'; +import { FolderTabsSkeleton } from '@/features/conversations'; export default function ConversationsLoading() { return ( diff --git a/web/app/src/app/(authenticated)/conversations/page.tsx b/web/app/src/app/(authenticated)/conversations/page.tsx index ea125e136ea..1b233de0315 100644 --- a/web/app/src/app/(authenticated)/conversations/page.tsx +++ b/web/app/src/app/(authenticated)/conversations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { ConversationSplitView } from '@/components/conversations/ConversationSplitView'; +import { ConversationSplitView } from '@/features/conversations'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/fair-use/page.tsx b/web/app/src/app/(authenticated)/fair-use/page.tsx index 7bf1dd9c4a0..b089ae0f8d9 100644 --- a/web/app/src/app/(authenticated)/fair-use/page.tsx +++ b/web/app/src/app/(authenticated)/fair-use/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { FairUseStatus } from '@/components/fair-use/FairUseStatus'; +import { FairUseStatus } from '@/features/fair-use'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/help/page.tsx b/web/app/src/app/(authenticated)/help/page.tsx index ce578e7d61f..721b60ef7b5 100644 --- a/web/app/src/app/(authenticated)/help/page.tsx +++ b/web/app/src/app/(authenticated)/help/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { useAuth } from '@/components/auth/AuthProvider'; +import { useAuth } from '@/features/auth'; import { crispEmbedUrl } from '@/lib/support'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/home/page.tsx b/web/app/src/app/(authenticated)/home/page.tsx index 9823eda8962..9525d041fbe 100644 --- a/web/app/src/app/(authenticated)/home/page.tsx +++ b/web/app/src/app/(authenticated)/home/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { HomePage } from '@/components/home/HomePage'; +import { HomePage } from '@/features/home'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/layout.tsx b/web/app/src/app/(authenticated)/layout.tsx index 484dd6d3436..2c9a3fa723e 100644 --- a/web/app/src/app/(authenticated)/layout.tsx +++ b/web/app/src/app/(authenticated)/layout.tsx @@ -1,4 +1,4 @@ -import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { ProtectedRoute } from '@/features/auth'; import { MainLayout } from '@/components/layout/MainLayout'; import { StartupModals } from '@/components/ui/StartupModals'; diff --git a/web/app/src/app/(authenticated)/memories/page.tsx b/web/app/src/app/(authenticated)/memories/page.tsx index b94220039e1..7469d0e8101 100644 --- a/web/app/src/app/(authenticated)/memories/page.tsx +++ b/web/app/src/app/(authenticated)/memories/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { MemoriesPage } from '@/components/memories/MemoriesPage'; +import { MemoriesPage } from '@/features/memories'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/settings/page.tsx b/web/app/src/app/(authenticated)/settings/page.tsx index 87483cedde5..c30a6ce78e9 100644 --- a/web/app/src/app/(authenticated)/settings/page.tsx +++ b/web/app/src/app/(authenticated)/settings/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { SettingsPage } from '@/components/settings/SettingsPage'; +import { SettingsPage } from '@/features/settings'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(authenticated)/tasks/page.tsx b/web/app/src/app/(authenticated)/tasks/page.tsx index 4f55dc8792e..249360e727f 100644 --- a/web/app/src/app/(authenticated)/tasks/page.tsx +++ b/web/app/src/app/(authenticated)/tasks/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { TaskHub } from '@/components/tasks/TaskHub'; +import { TaskHub } from '@/features/tasks'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(public)/apps/[id]/page.tsx b/web/app/src/app/(public)/apps/[id]/page.tsx index fc7bbb90da3..2159bcf74a4 100644 --- a/web/app/src/app/(public)/apps/[id]/page.tsx +++ b/web/app/src/app/(public)/apps/[id]/page.tsx @@ -8,8 +8,8 @@ import { transformToPlugin, type V2AppData, } from '@/lib/api/public'; -import { CompactPluginCard } from '@/components/marketplace/plugin-card/CompactPluginCard'; -import { CategoryBreadcrumb } from '@/components/marketplace/CategoryBreadcrumb'; +import { CompactPluginCard } from '@/features/marketplace'; +import { CategoryBreadcrumb } from '@/features/marketplace'; import { BreadcrumbJsonLd, SoftwareAppJsonLd } from '@/components/seo/JsonLd'; import { Calendar, User, FolderOpen, Puzzle, ArrowRight, DollarSign } from 'lucide-react'; import Image from '@tschk/moonshine-next/image'; diff --git a/web/app/src/app/(public)/apps/__tests__/marketplacePage.test.tsx b/web/app/src/app/(public)/apps/__tests__/marketplacePage.test.tsx index 9402116cdea..8cff6cb2eb0 100644 --- a/web/app/src/app/(public)/apps/__tests__/marketplacePage.test.tsx +++ b/web/app/src/app/(public)/apps/__tests__/marketplacePage.test.tsx @@ -14,7 +14,7 @@ vi.mock('@/lib/api/public', () => ({ capabilities: new Set(), }), })); -vi.mock('@/components/marketplace/AppList', () => ({ +vi.mock('@/features/marketplace', () => ({ default: ({ initialPlugins, }: { @@ -26,8 +26,8 @@ vi.mock('@/components/marketplace/AppList', () => ({ ))}
), + PromoCard: () => null, })); -vi.mock('@/components/marketplace/PromoCard', () => ({ PromoCard: () => null })); vi.mock('@/components/seo/JsonLd', () => ({ CollectionPageJsonLd: () => null })); vi.mock('@/moonshine/register-client-route', () => ({ registerMoonshineRoute: () => {}, diff --git a/web/app/src/app/(public)/apps/category/[category]/page.tsx b/web/app/src/app/(public)/apps/category/[category]/page.tsx index 9e4811f9496..a25411fb6d9 100644 --- a/web/app/src/app/(public)/apps/category/[category]/page.tsx +++ b/web/app/src/app/(public)/apps/category/[category]/page.tsx @@ -2,13 +2,13 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams } from '@tschk/moonshine-next/navigation'; -import { CompactPluginCard } from '@/components/marketplace/plugin-card/CompactPluginCard'; -import { FeaturedPluginCard } from '@/components/marketplace/plugin-card/FeaturedPluginCard'; -import { ScrollableCategoryNav } from '@/components/marketplace/ScrollableCategoryNav'; -import { CategoryBreadcrumb } from '@/components/marketplace/CategoryBreadcrumb'; -import { CategoryHeader } from '@/components/marketplace/CategoryHeader'; +import { CompactPluginCard } from '@/features/marketplace'; +import { FeaturedPluginCard } from '@/features/marketplace'; +import { ScrollableCategoryNav } from '@/features/marketplace'; +import { CategoryBreadcrumb } from '@/features/marketplace'; +import { CategoryHeader } from '@/features/marketplace'; import { getAllAppsV2, transformToPlugin } from '@/lib/api/public'; -import { getCategoryMetadata } from '@/components/marketplace/category'; +import { getCategoryMetadata } from '@/features/marketplace'; import { BreadcrumbJsonLd, CollectionPageJsonLd } from '@/components/seo/JsonLd'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(public)/apps/page.tsx b/web/app/src/app/(public)/apps/page.tsx index b31b3893034..6020635b78f 100644 --- a/web/app/src/app/(public)/apps/page.tsx +++ b/web/app/src/app/(public)/apps/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { getAllAppsV2, getAppsV2, transformToPlugin } from '@/lib/api/public'; -import AppList from '@/components/marketplace/AppList'; -import { PromoCard } from '@/components/marketplace/PromoCard'; +import AppList from '@/features/marketplace'; +import { PromoCard } from '@/features/marketplace'; import { CollectionPageJsonLd } from '@/components/seo/JsonLd'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/(public)/layout.tsx b/web/app/src/app/(public)/layout.tsx index 107f0241e34..e78231dec19 100644 --- a/web/app/src/app/(public)/layout.tsx +++ b/web/app/src/app/(public)/layout.tsx @@ -2,22 +2,16 @@ import { MarketplaceHeader } from '@/components/layout/MarketplaceHeader'; import { Footer } from '@/components/layout/Footer'; -import { LoginPanel } from '@/components/auth/LoginPanel'; -import { useAuth } from '@/components/auth/AuthProvider'; +import { LoginPanel } from '@/features/auth'; +import { useAuth } from '@/features/auth'; -export default function PublicLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function PublicLayout({ children }: { children: React.ReactNode }) { const { isLoginPanelOpen, closeLoginPanel } = useAuth(); return (
-
- {children} -
+
{children}
diff --git a/web/app/src/app/case/[ref]/page.tsx b/web/app/src/app/case/[ref]/page.tsx index d0fa7e4e8d8..95dcced6a43 100644 --- a/web/app/src/app/case/[ref]/page.tsx +++ b/web/app/src/app/case/[ref]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams } from '@tschk/moonshine-next/navigation'; -import { CaseStatusView } from '@/components/fair-use/CaseStatusView'; +import { CaseStatusView } from '@/features/fair-use'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; // This page runs entirely in the browser, on the web origin. The backend diff --git a/web/app/src/app/case/__tests__/caseStatusOrigin.test.tsx b/web/app/src/app/case/__tests__/caseStatusOrigin.test.tsx index ba07bc5c613..bca685b494e 100644 --- a/web/app/src/app/case/__tests__/caseStatusOrigin.test.tsx +++ b/web/app/src/app/case/__tests__/caseStatusOrigin.test.tsx @@ -7,7 +7,7 @@ vi.mock('@tschk/moonshine-next/navigation', () => ({ vi.mock('@/moonshine/register-client-route', () => ({ registerMoonshineRoute: () => {}, })); -vi.mock('@/components/fair-use/CaseStatusView', () => ({ +vi.mock('@/features/fair-use', () => ({ CaseStatusView: ({ status }: { status: { stage: string } | null }) => (
{status ? status.stage : 'not-found'}
), diff --git a/web/app/src/app/layout.tsx b/web/app/src/app/layout.tsx index f90e8ff052c..03cc9a97662 100644 --- a/web/app/src/app/layout.tsx +++ b/web/app/src/app/layout.tsx @@ -1,5 +1,5 @@ -import { AuthProvider } from '@/components/auth/AuthProvider'; -import { RecordingProvider, RecordingController } from '@/components/recording'; +import { AuthProvider } from '@/features/auth'; +import { RecordingProvider, RecordingController } from '@/features/recording'; import { ToastProvider } from '@/components/ui/Toast'; import { PublicBuildCanary } from '@/components/public-build-canary'; diff --git a/web/app/src/app/login/LoginClient.tsx b/web/app/src/app/login/LoginClient.tsx index 0cbe7127353..1f83a295654 100644 --- a/web/app/src/app/login/LoginClient.tsx +++ b/web/app/src/app/login/LoginClient.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from '@tschk/moonshine-next/navigation'; import Image from '@tschk/moonshine-next/image'; import Link from '@tschk/moonshine-next/link'; import { motion } from 'framer-motion'; -import { useAuth } from '@/components/auth/AuthProvider'; +import { useAuth } from '@/features/auth'; import { cn } from '@/lib/utils'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { isFirebaseAuthConfigured } from '@/lib/firebase'; diff --git a/web/app/src/app/login/__tests__/LoginClient.test.ts b/web/app/src/app/login/__tests__/LoginClient.test.ts index cf32ce357a3..b9eb4e4be64 100644 --- a/web/app/src/app/login/__tests__/LoginClient.test.ts +++ b/web/app/src/app/login/__tests__/LoginClient.test.ts @@ -25,7 +25,7 @@ vi.mock('@tschk/moonshine-next/link', () => ({ createElement('a', { href }, children), })); -vi.mock('@/components/auth/AuthProvider', () => ({ +vi.mock('@/features/auth', () => ({ useAuth: () => ({ user: authUser, loading: false, diff --git a/web/app/src/app/record/page.tsx b/web/app/src/app/record/page.tsx index d34b135b6de..54497a6bdfb 100644 --- a/web/app/src/app/record/page.tsx +++ b/web/app/src/app/record/page.tsx @@ -4,12 +4,12 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Mic, ArrowLeft } from 'lucide-react'; import Link from '@tschk/moonshine-next/link'; -import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { ProtectedRoute } from '@/features/auth'; import { MainLayout } from '@/components/layout/MainLayout'; -import { useRecordingContext } from '@/components/recording/RecordingContext'; -import { AudioModeSelector } from '@/components/recording/AudioModeSelector'; -import { RecordingControls } from '@/components/recording/RecordingControls'; -import { LiveTranscript } from '@/components/recording/LiveTranscript'; +import { useRecordingContext } from '@/features/recording'; +import { AudioModeSelector } from '@/features/recording'; +import { RecordingControls } from '@/features/recording'; +import { LiveTranscript } from '@/features/recording'; import { cn } from '@/lib/utils'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; diff --git a/web/app/src/app/record/popout/page.tsx b/web/app/src/app/record/popout/page.tsx index 9ab45368b67..362c2e0a43f 100644 --- a/web/app/src/app/record/popout/page.tsx +++ b/web/app/src/app/record/popout/page.tsx @@ -10,7 +10,7 @@ import { requestCurrentState, type RecordingBroadcastMessage, } from '@/lib/recordingBroadcast'; -import type { RecordingState, AudioMode } from '@/components/recording/RecordingContext'; +import type { RecordingState, AudioMode } from '@/features/recording'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; // Extended message type for start command with audio mode diff --git a/web/app/src/app/record/popout/transcript/page.tsx b/web/app/src/app/record/popout/transcript/page.tsx index a6426a27cef..6bd36571224 100644 --- a/web/app/src/app/record/popout/transcript/page.tsx +++ b/web/app/src/app/record/popout/transcript/page.tsx @@ -10,11 +10,7 @@ import { requestCurrentState, type RecordingBroadcastMessage, } from '@/lib/recordingBroadcast'; -import type { - RecordingState, - AudioMode, - TranscriptSegment, -} from '@/components/recording/RecordingContext'; +import type { RecordingState, AudioMode, TranscriptSegment } from '@/features/recording'; import { registerMoonshineRoute } from '@/moonshine/register-client-route'; // Extended message type for start command diff --git a/web/app/src/app/sitemap.ts b/web/app/src/app/sitemap.ts index 0f85eb75c0d..f4354bee6e6 100644 --- a/web/app/src/app/sitemap.ts +++ b/web/app/src/app/sitemap.ts @@ -1,5 +1,5 @@ import { getApprovedApps } from '@/lib/api/public'; -import { categoryMetadata } from '@/components/marketplace/category'; +import { categoryMetadata } from '@/features/marketplace'; type SitemapEntry = { url: string; diff --git a/web/app/src/components/layout/BottomNavigation.tsx b/web/app/src/components/layout/BottomNavigation.tsx index e89ca8f6a92..49b572f0396 100644 --- a/web/app/src/components/layout/BottomNavigation.tsx +++ b/web/app/src/components/layout/BottomNavigation.tsx @@ -5,7 +5,7 @@ import Link from '@tschk/moonshine-next/link'; import { motion } from 'framer-motion'; import { GanttChartSquare, House, Mic, CheckSquare, Menu } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useRecordingContext } from '@/components/recording/RecordingContext'; +import { useRecordingContext } from '@/features/recording'; interface BottomNavigationProps { onOpenSidebar: () => void; @@ -43,7 +43,7 @@ export function BottomNavigation({ onOpenSidebar }: BottomNavigationProps) { 'lg:hidden', // Only show on mobile 'bg-bg-secondary/80 backdrop-blur-md', 'border-t border-bg-tertiary', - 'pb-safe' // Safe area inset for devices with home indicators + 'pb-safe', // Safe area inset for devices with home indicators )} aria-label="Primary navigation" > @@ -92,7 +92,7 @@ export function BottomNavigation({ onOpenSidebar }: BottomNavigationProps) { 'flex flex-col items-center justify-center', 'w-14 h-14 rounded-xl', 'text-text-tertiary hover:text-text-secondary', - 'transition-colors duration-150' + 'transition-colors duration-150', )} aria-label="More options" > diff --git a/web/app/src/components/layout/MainLayout.tsx b/web/app/src/components/layout/MainLayout.tsx index bdb672e7221..7d3acf77f59 100644 --- a/web/app/src/components/layout/MainLayout.tsx +++ b/web/app/src/components/layout/MainLayout.tsx @@ -5,21 +5,18 @@ import { usePathname, useSearchParams } from '@tschk/moonshine-next/navigation'; import { motion } from 'framer-motion'; import dynamic from '@tschk/moonshine-next/dynamic'; import { Sidebar, MobileMenuButton } from './Sidebar'; -import { ChatProvider, useChat as useChatContext } from '@/components/chat/ChatContext'; +import { ChatProvider, useChat as useChatContext } from '@/features/chat'; import { BottomNavigation } from './BottomNavigation'; -import { - NotificationProvider, - useNotificationContext, -} from '@/components/notifications/NotificationContext'; -import { HeaderRecordingIndicator } from '@/components/recording'; -import { getChatApps } from '@/lib/api'; +import { NotificationProvider, useNotificationContext } from '@/features/notifications'; +import { HeaderRecordingIndicator } from '@/features/recording'; +import { getChatApps } from '@/features/connectors'; import { cn } from '@/lib/utils'; -import { MemoriesPrefetcher } from '@/components/memories/MemoriesPrefetcher'; -import { ChatBubble } from '@/components/chat/ChatBubble'; +import { MemoriesPrefetcher } from '@/features/memories'; +import { ChatBubble } from '@/features/chat'; // Dynamic imports for panels - not visible on initial load const ChatPanel = dynamic( - () => import('@/components/chat/ChatPanel').then((mod) => ({ default: mod.ChatPanel })), + () => import('@/features/chat').then((mod) => ({ default: mod.ChatPanel })), { ssr: false, }, @@ -27,7 +24,7 @@ const ChatPanel = dynamic( const NotificationCenter = dynamic( () => - import('@/components/notifications/NotificationCenter').then((mod) => ({ + import('@/features/notifications').then((mod) => ({ default: mod.NotificationCenter, })), { diff --git a/web/app/src/components/layout/MarketplaceHeader.tsx b/web/app/src/components/layout/MarketplaceHeader.tsx index 91f915dbefd..72ddb0f39ac 100644 --- a/web/app/src/components/layout/MarketplaceHeader.tsx +++ b/web/app/src/components/layout/MarketplaceHeader.tsx @@ -2,7 +2,7 @@ import Link from '@tschk/moonshine-next/link'; import Image from '@tschk/moonshine-next/image'; -import { useAuth } from '@/components/auth/AuthProvider'; +import { useAuth } from '@/features/auth'; import { cn } from '@/lib/utils'; export function MarketplaceHeader() { diff --git a/web/app/src/components/layout/Sidebar.tsx b/web/app/src/components/layout/Sidebar.tsx index bdb96ea8637..410eb36a692 100644 --- a/web/app/src/components/layout/Sidebar.tsx +++ b/web/app/src/components/layout/Sidebar.tsx @@ -48,10 +48,10 @@ function DiscordIcon({ className }: { className?: string }) { ); } -import { useAuth } from '@/components/auth/AuthProvider'; -import { useNotificationContext } from '@/components/notifications/NotificationContext'; +import { useAuth } from '@/features/auth'; +import { useNotificationContext } from '@/features/notifications'; import { cn } from '@/lib/utils'; -import { SETTINGS_SECTIONS, type SettingsSectionId } from '@/lib/settingsSections'; +import { SETTINGS_SECTIONS, type SettingsSectionId } from '@/features/settings'; import { PROFILE_MENU_MAX_HEIGHT } from '@/lib/profileMenu'; import { ConfettiBurst } from '@/components/ui/ConfettiBurst'; diff --git a/web/app/src/components/layout/__tests__/Sidebar.test.tsx b/web/app/src/components/layout/__tests__/Sidebar.test.tsx index c31d60ba3b6..ad26b35c291 100644 --- a/web/app/src/components/layout/__tests__/Sidebar.test.tsx +++ b/web/app/src/components/layout/__tests__/Sidebar.test.tsx @@ -19,13 +19,13 @@ vi.mock('@tschk/moonshine-next/image', () => ({ {alt} ), })); -vi.mock('@/components/auth/AuthProvider', () => ({ +vi.mock('@/features/auth', () => ({ useAuth: () => ({ user: { displayName: 'Omi User', email: 'user@example.com', photoURL: null }, signOut: vi.fn(), }), })); -vi.mock('@/components/notifications/NotificationContext', () => ({ +vi.mock('@/features/notifications', () => ({ useNotificationContext: () => ({ toggleNotificationCenter: vi.fn(), unreadCount: 0 }), })); diff --git a/web/app/src/components/recording/index.ts b/web/app/src/components/recording/index.ts deleted file mode 100644 index 69053b0de0e..00000000000 --- a/web/app/src/components/recording/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Recording feature exports -export { RecordingProvider, useRecordingContext } from './RecordingContext'; -export type { AudioMode, RecordingState, TranscriptSegment } from './RecordingContext'; -export { RecordingController } from './RecordingController'; -export { RecordingWidget } from './RecordingWidget'; -export { AudioModeSelector } from './AudioModeSelector'; -export { RecordingControls } from './RecordingControls'; -export { LiveTranscript, LiveTranscriptCompact } from './LiveTranscript'; -export { HeaderRecordingIndicator } from './HeaderRecordingIndicator'; diff --git a/web/app/src/components/settings/SettingsPage.tsx b/web/app/src/components/settings/SettingsPage.tsx deleted file mode 100644 index e92c41d3ba2..00000000000 --- a/web/app/src/components/settings/SettingsPage.tsx +++ /dev/null @@ -1,3571 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { useRouter, useSearchParams } from '@tschk/moonshine-next/navigation'; -import Image from '@tschk/moonshine-next/image'; -import Link from '@tschk/moonshine-next/link'; -import { - User, - Bell, - Shield, - Code, - LogOut, - Trash2, - Globe, - Clock, - ChevronDown, - Loader2, - ExternalLink, - Copy, - Check, - AlertTriangle, - BarChart3, - Puzzle, - Plus, - X, - Key, - Webhook, - BookOpen, - MessageSquare, - Calendar, - Github, - Twitter, - Settings, - Brain, - Server, - Monitor, - Download, - Network, - Mic, - Radio, - FileText, - FlaskConical, - Activity, - UserPlus, - Lightbulb, - Target, - ArrowLeft, - Crown, - ChevronRight, - Zap, - CreditCard, - Scale, -} from 'lucide-react'; -import { useAuth } from '@/components/auth/AuthProvider'; -import { useToast } from '@/components/ui/Toast'; -import { cn } from '@/lib/utils'; -import { PageHeader } from '@/components/layout/PageHeader'; -import { - CLAUDE_CONNECTOR_OAUTH, - SECTION_INFO, - SIGNED_OUT_DESTINATION, - isSettingsSectionId, - type SettingsSectionId, -} from '@/lib/settingsSections'; -import { - getUserLanguage, - setUserLanguage, - getDailySummarySettings, - updateDailySummarySettings, - getRecordingPermission, - setRecordingPermission, - getTrainingDataOptIn, - setTrainingDataOptIn, - deleteAccount, - getAllUsageData, - getUserSubscription, - getCustomVocabulary, - updateCustomVocabulary, - getDeveloperWebhook, - getDeveloperWebhooksStatus, - setDeveloperWebhook, - enableDeveloperWebhook, - disableDeveloperWebhook, - getDeveloperApiKeys, - createDeveloperApiKey, - deleteDeveloperApiKey, - getMcpApiKeys, - createMcpApiKey, - deleteMcpApiKey, - exportAllData, - deleteKnowledgeGraph, - getAvailablePlans, - createCheckoutSession, - upgradeSubscription, - cancelSubscription, - getCustomerPortal, -} from '@/lib/api'; -import { SUPPORTED_LANGUAGES, API_KEY_SCOPES } from '@/types/user'; -import { decodePlan, planGrantsPaidCapability } from '@/types/user'; -import type { - DailySummarySettings, - UserUsage, - UserSubscription, - AllUsageData, - DeveloperWebhooks, - DeveloperApiKey, - McpApiKey, - UsageHistoryPoint, - PricingOption, -} from '@/types/user'; - -// ============================================================================ -// Types -// ============================================================================ - -type SettingsSection = SettingsSectionId; - -// ============================================================================ -// Reusable Components -// ============================================================================ - -function Toggle({ - enabled, - onChange, - disabled = false, -}: { - enabled: boolean; - onChange: (enabled: boolean) => void; - disabled?: boolean; -}) { - return ( - - ); -} - -function Card({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( -
- {children} -
- ); -} - -function SettingRow({ - label, - description, - children, -}: { - label: string; - description?: string; - children: React.ReactNode; -}) { - return ( -
-
-

{label}

- {description && ( -

- {description} -

- )} -
- {children} -
- ); -} - -function Dropdown({ - value, - options, - onChange, - placeholder = 'Select...', -}: { - value: string; - options: { value: string; label: string }[]; - onChange: (value: string) => void; - placeholder?: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const selectedOption = options.find((o) => o.value === value); - - return ( -
- - - {isOpen && ( -
- {options.map((option) => ( - - ))} -
- )} -
- ); -} - -function HourPicker({ - value, - onChange, -}: { - value: number; - onChange: (hour: number) => void; -}) { - const hours = Array.from({ length: 24 }, (_, i) => { - const hour = i; - const period = hour >= 12 ? 'PM' : 'AM'; - const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - return { - value: hour.toString(), - label: `${displayHour}:00 ${period}`, - }; - }); - - return ( - onChange(parseInt(v))} - placeholder="Select time" - /> - ); -} - -function ConfirmDialog({ - isOpen, - title, - message, - confirmLabel, - onConfirm, - onCancel, - isDestructive = false, - isLoading = false, -}: { - isOpen: boolean; - title: string; - message: string; - confirmLabel: string; - onConfirm: () => void; - onCancel: () => void; - isDestructive?: boolean; - isLoading?: boolean; -}) { - if (!isOpen) return null; - - return ( -
-
-
-
-
- -
-
-

{title}

-

{message}

-
-
-
- - -
-
-
- ); -} - -// ============================================================================ -// Profile Section -// ============================================================================ - -function ProfileSection({ - user, - onCopyUserId, - language, - vocabulary, - onLanguageChange, - onAddWord, - onRemoveWord, - dailySummary, - onDailySummaryToggle, - onDailySummaryHourChange, -}: { - user: any; - onCopyUserId: () => void; - language: string; - vocabulary: string[]; - onLanguageChange: (lang: string) => void; - onAddWord: (word: string) => void; - onRemoveWord: (word: string) => void; - dailySummary: DailySummarySettings; - onDailySummaryToggle: (enabled: boolean) => void; - onDailySummaryHourChange: (hour: number) => void; -}) { - const [copiedUserId, setCopiedUserId] = useState(false); - const [newWord, setNewWord] = useState(''); - - const handleCopy = () => { - onCopyUserId(); - setCopiedUserId(true); - setTimeout(() => setCopiedUserId(false), 2000); - }; - - const handleAddWord = () => { - if (newWord.trim()) { - onAddWord(newWord.trim()); - setNewWord(''); - } - }; - - const languageOptions = SUPPORTED_LANGUAGES.map((l) => ({ - value: l.code, - label: l.name, - })); - - return ( -
- {/* Account Info */} -
-

- Account -

- -
-
- {user?.photoURL ? ( - {user.displayName - ) : ( -
- {user?.displayName?.charAt(0) || 'U'} -
- )} -
-
-

- {user?.displayName || 'User'} -

-

{user?.email}

-
-
-
- - - -
- - {user?.uid?.slice(0, 8)}...{user?.uid?.slice(-4)} - - -
-
-
-
- - {/* Language & Transcription */} -
-

- Language & Transcription -

- - - - - -
- - {/* Custom Vocabulary */} -
-

- Custom Vocabulary -

- -
-

- Add words or phrases to improve transcription accuracy -

- -
- setNewWord(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddWord()} - placeholder="Enter a word or phrase" - className={cn( - 'flex-1 px-4 py-2.5 rounded-xl', - 'bg-bg-tertiary border border-white/[0.06]', - 'text-text-primary placeholder:text-text-quaternary', - 'focus:outline-none focus:border-white/25', - )} - /> - -
- - {vocabulary.length > 0 && ( -
- {vocabulary.map((word) => ( - - {word} - - - ))} -
- )} - - {vocabulary.length === 0 && ( -

- No custom vocabulary added yet -

- )} -
-
-
- - {/* Notifications */} -
-

- Notifications -

- - - - - - {dailySummary.enabled && ( - - - - )} - -
-
- ); -} - -// ============================================================================ -// Privacy Section -// ============================================================================ - -function PrivacySection({ - recordingPermission, - trainingDataOptIn, - onRecordingChange, - onTrainingDataChange, -}: { - recordingPermission: boolean; - trainingDataOptIn: boolean; - onRecordingChange: (enabled: boolean) => void; - onTrainingDataChange: (enabled: boolean) => void; -}) { - return ( -
- - - - - - - - - - - -
-
- -
-
-

Your Privacy Matters

-

- Your data is encrypted and never shared with third parties. You have full - control over what data is collected and stored. -

- - Learn more about our privacy policy - - -
-
-
-
- ); -} - -// ============================================================================ -// Plan & Usage Section -// ============================================================================ - -type UsagePeriod = 'today' | 'monthly' | 'yearly' | 'all_time'; - -const PERIOD_LABELS: Record = { - today: 'Today', - monthly: 'This Month', - yearly: 'This Year', - all_time: 'All Time', -}; - -function UsageChart({ - history, - period, -}: { - history?: UsageHistoryPoint[]; - period: UsagePeriod; -}) { - const [selectedMetric, setSelectedMetric] = useState< - 'listening' | 'words' | 'insights' | 'memories' - >('listening'); - - if (!history || history.length === 0) { - return ( - -

No activity data available

-
- ); - } - - // For all_time with many data points, aggregate by year - let dataToProcess = history; - if (period === 'all_time' && history.length > 12) { - // Group by year and aggregate - const yearlyData = new Map(); - history.forEach((point) => { - const date = new Date(point.date); - const key = String(date.getFullYear()); - const existing = yearlyData.get(key); - if (existing) { - yearlyData.set(key, { - date: `${key}-01-01`, - transcription_seconds: - existing.transcription_seconds + point.transcription_seconds, - words_transcribed: existing.words_transcribed + point.words_transcribed, - insights_gained: existing.insights_gained + point.insights_gained, - memories_created: existing.memories_created + point.memories_created, - }); - } else { - yearlyData.set(key, { ...point, date: `${key}-01-01` }); - } - }); - dataToProcess = Array.from(yearlyData.values()).sort((a, b) => - a.date.localeCompare(b.date), - ); - } - - // Process history data for display - const processedData = dataToProcess.map((point, index) => { - // Parse date string - handles both "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" formats - let label = ''; - - if (period === 'today') { - // For today, extract hour from ISO format "2026-01-02T00:00:00Z" - const timeMatch = point.date.match(/T(\d{2}):/); - const hour = timeMatch ? parseInt(timeMatch[1], 10) : 0; - label = `${hour}:00`; - } else { - // For other periods, parse the date portion "YYYY-MM-DD" - const datePart = point.date.split('T')[0]; // Get date part before 'T' - const [year, month, day] = datePart.split('-').map(Number); - - if (period === 'monthly') { - label = `${day}`; - } else if (period === 'yearly') { - label = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ][month - 1]; - } else { - // For all_time, show year - label = String(year); - } - } - return { ...point, label, index }; - }); - - // Get value based on selected metric - const getValue = (d: UsageHistoryPoint) => { - switch (selectedMetric) { - case 'listening': - return d.transcription_seconds / 60; // Convert to minutes - case 'words': - return d.words_transcribed; - case 'insights': - return d.insights_gained; - case 'memories': - return d.memories_created; - } - }; - - // Format value for display - const formatValue = (value: number) => { - if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; - if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; - return Math.round(value).toLocaleString(); - }; - - // Format value with unit - const formatValueWithUnit = (value: number) => { - const formatted = formatValue(value); - switch (selectedMetric) { - case 'listening': - return `${formatted} min`; - case 'words': - return formatted; - case 'insights': - return formatted; - case 'memories': - return formatted; - } - }; - - // Find max value for scaling - const maxValue = Math.max(...processedData.map((d) => getValue(d)), 1); - - const metricConfig = [ - { key: 'listening' as const, color: 'rgb(96, 165, 250)', label: 'Listening' }, - { key: 'words' as const, color: 'rgb(74, 222, 128)', label: 'Words' }, - { key: 'insights' as const, color: 'rgb(251, 146, 60)', label: 'Insights' }, - { key: 'memories' as const, color: 'rgb(192, 132, 252)', label: 'Memories' }, - ]; - - const currentMetric = metricConfig.find((m) => m.key === selectedMetric)!; - - return ( - - {/* Header with metric selector */} -
-

Activity Over Time

-
- {metricConfig.map((metric) => ( - - ))} -
-
- - {/* Bar Chart */} -
- {processedData.map((d, i) => { - const value = getValue(d); - // Calculate height in pixels (max 100px), with minimum 8px for visibility - const maxBarHeight = 100; - const barHeight = Math.max((value / maxValue) * maxBarHeight, 8); - // Convert rgb(r,g,b) to rgba format for opacity - const rgbMatch = currentMetric.color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - const rgba = rgbMatch - ? `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, 0.5)` - : currentMetric.color; - return ( -
- {/* Value on top */} - - {formatValueWithUnit(value)} - - {/* Bar with fixed pixel height */} -
- {/* Label */} - - {d.label} - -
- ); - })} -
- - ); -} - -type PlanUsageTab = 'plan' | 'usage'; - -function UnknownPlanCard() { - return ( - -
-
- -
-
-

Plan unavailable

-

- This account uses a plan that this version of Omi does not recognize yet. - Plan features are unavailable until the plan can be identified. -

-
-
-
- ); -} - -function UsageSectionContent({ - allUsage, - subscription, - onSubscriptionUpdate, - cachedPlans, -}: { - allUsage: AllUsageData | null; - subscription: UserSubscription | null; - onSubscriptionUpdate: () => void; - cachedPlans: PricingOption[] | null; -}) { - const [activeTab, setActiveTab] = useState('plan'); - const [selectedPeriod, setSelectedPeriod] = useState('all_time'); - const [selectedPriceId, setSelectedPriceId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [showCancelConfirm, setShowCancelConfirm] = useState(false); - const [isCanceling, setIsCanceling] = useState(false); - const [showUpgradeOptions, setShowUpgradeOptions] = useState(false); - - // Set initial selected price when plans load, and keep it in sync when the - // subscription's current price changes (e.g. after a plan change or - // cancellation) so a pending-cancellation user isn't left on a stale option. - const currentPriceId = subscription?.current_price_id; - useEffect(() => { - if (cachedPlans && cachedPlans.length > 0) { - const activePlan = cachedPlans.find((p) => p.is_active || p.id === currentPriceId); - if (activePlan) { - setSelectedPriceId(activePlan.id); - } else if (!selectedPriceId) { - setSelectedPriceId(cachedPlans[0].id); - } - } - }, [cachedPlans, currentPriceId]); - - const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - return `${minutes}m`; - }; - - const formatNumber = (num: number) => { - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}k`; - } - return num.toString(); - }; - - const formatDate = (timestamp: number) => { - return new Date(timestamp * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - - // Get usage for selected period - const usage = allUsage ? allUsage[selectedPeriod] : null; - const monthlyUsage = allUsage?.monthly; - const periods: UsagePeriod[] = ['today', 'monthly', 'yearly', 'all_time']; - - // Default limits for basic plan (1200 minutes = 72000 seconds) - const limits = { - transcription_seconds: 72000, // 1200 minutes - words_transcribed: 50000, - insights_gained: 100, - memories_created: 50, - }; - - const planIdentity = subscription - ? subscription.plan_identity ?? decodePlan(subscription.plan) - : null; - const isUnlimited = planIdentity ? planGrantsPaidCapability(planIdentity) : false; - const isUnknownPlan = planIdentity?.kind === 'unknown'; - const isCancelingSubscription = subscription?.cancel_at_period_end; - - // Calculate usage percentages for basic plan - const getUsagePercent = (used: number, limit: number) => { - if (limit <= 0) return 0; - return Math.min((used / limit) * 100, 100); - }; - - // Sort pricing options: monthly first, then annual - const sortedOptions = cachedPlans - ? [...cachedPlans].sort((a, b) => { - const aIsAnnual = - a.interval === 'year' || a.title?.toLowerCase().includes('annual'); - const bIsAnnual = - b.interval === 'year' || b.title?.toLowerCase().includes('annual'); - return (aIsAnnual ? 1 : 0) - (bIsAnnual ? 1 : 0); - }) - : []; - - const selectedOption = cachedPlans?.find((p) => p.id === selectedPriceId); - - // Default features for unlimited plan - const defaultFeatures = [ - 'Unlimited conversations', - 'Unlimited memories', - 'Priority processing', - 'Advanced insights', - ]; - - const handleSubscribe = async () => { - if (!selectedPriceId) return; - - setIsLoading(true); - setError(null); - - try { - const isCurrentPlan = - selectedOption?.is_active || - selectedOption?.id === subscription?.current_price_id; - - if (isCancelingSubscription && selectedPriceId !== subscription?.current_price_id) { - setError('Plan changes are available after your current subscription ends.'); - return; - } - - if (isUnlimited && !isCancelingSubscription && !isCurrentPlan) { - const result = await upgradeSubscription(selectedPriceId); - if (result?.status === 'success' || result?.scheduled_start) { - onSubscriptionUpdate(); - } else { - setError(result?.message || 'Failed to upgrade plan'); - } - } else { - const result = await createCheckoutSession(selectedPriceId); - if (result?.url) { - window.open(result.url, '_blank'); - const handleFocus = () => { - onSubscriptionUpdate(); - window.removeEventListener('focus', handleFocus); - }; - window.addEventListener('focus', handleFocus); - } else if (result?.status === 'reactivated') { - onSubscriptionUpdate(); - } else { - setError('Failed to create checkout session'); - } - } - } catch (err) { - setError('An error occurred. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - const handleManagePayment = async () => { - setIsLoading(true); - try { - const result = await getCustomerPortal(); - if (result?.url) { - window.open(result.url, '_blank'); - const handleFocus = () => { - onSubscriptionUpdate(); - window.removeEventListener('focus', handleFocus); - }; - window.addEventListener('focus', handleFocus); - } else { - setError('Failed to open payment portal'); - } - } catch (err) { - setError('Failed to open payment portal'); - } finally { - setIsLoading(false); - } - }; - - const handleCancelSubscription = async () => { - setIsCanceling(true); - try { - const result = await cancelSubscription(); - if (result?.status === 'success' || result?.cancel_at_period_end) { - onSubscriptionUpdate(); - setShowCancelConfirm(false); - } else { - setError(result?.message || 'Failed to cancel subscription'); - } - } catch (err) { - setError('Failed to cancel subscription'); - } finally { - setIsCanceling(false); - } - }; - - return ( -
- {/* Tab Switcher */} -
- - -
- - {/* Tab Content */} - {activeTab === 'plan' ? ( - /* PLAN TAB - Unknown plans remain neutral; known plans choose Basic vs paid. */ -
- {isUnknownPlan ? ( - - ) : !isUnlimited ? ( - /* BASIC PLAN VIEW */ - <> - {/* Current Plan Card */} - - {/* Header */} -
-
-
- -
-
-

- Basic Plan -

-

Free tier

-
-
-
- - {subscription?.stripe_subscription_id && ( - - )} -
-
- - {/* Monthly Listening Usage */} -
-
- - - Monthly Listening Limit - -
-
- - {monthlyUsage - ? Math.round(monthlyUsage.transcription_seconds / 60) - : 0} - - / 1,200 min - - - - {monthlyUsage - ? 1200 - Math.round(monthlyUsage.transcription_seconds / 60) - : 1200}{' '} - min left - -
-
-
-
-
- - {/* What's Included - Checklist */} -
-

- What's included -

-
-
-
- -
- - - 1,200 minutes - {' '} - of listening per month - (limited) - -
-
-
- -
- - Unlimited{' '} - words transcribed - -
-
-
- -
- - Unlimited{' '} - insights - -
-
-
- -
- - Unlimited{' '} - memories - -
-
-
- - - {/* Billing error surfaced in the Basic view (not only the upgrade panel) */} - {error && !showUpgradeOptions && ( -
- -

{error}

-
- )} - - {/* Upgrade Options (shown when clicked) */} - {showUpgradeOptions && ( - -
-
-

- Choose a Plan -

-

- Unlock unlimited listening time -

-
- -
- - {/* Plan Selection */} - {sortedOptions.length > 0 ? ( -
- {sortedOptions.map((option) => { - const isSelected = selectedPriceId === option.id; - const isAnnual = - option.interval === 'year' || - option.title?.toLowerCase().includes('annual'); - - return ( - - ); - })} -
- ) : ( -
- -
- )} - - {/* Error Message */} - {error && ( -
- -

{error}

-
- )} - - -
- )} - - {/* This Month Stats - Compact Single Row */} - -

- This month -

-
-
-
- -
-

- {monthlyUsage - ? formatDuration(monthlyUsage.transcription_seconds) - : '0m'} -

-

Listening

-
-
-
- -
-

- {monthlyUsage ? formatNumber(monthlyUsage.words_transcribed) : '0'} -

-

Words

-
-
-
- -
-

- {monthlyUsage?.insights_gained || 0} -

-

Insights

-
-
-
- -
-

- {monthlyUsage?.memories_created || 0} -

-

Memories

-
-
-
- - ) : ( - /* UNLIMITED PLAN VIEW */ - <> - {/* Header */} -
-
- -
-
-

- {isCancelingSubscription ? 'Your Plan' : 'Manage Your Plan'} -

- {subscription?.current_period_end && ( -

- {isCancelingSubscription - ? `Cancels on ${formatDate(subscription.current_period_end)}` - : `Renews ${formatDate(subscription.current_period_end)}`} -

- )} -
-
- - {/* Plan Selection */} - {sortedOptions.length > 0 ? ( -
- {sortedOptions.map((option) => { - const isSelected = selectedPriceId === option.id; - const isCurrent = - option.is_active || option.id === subscription?.current_price_id; - const isAnnual = - option.interval === 'year' || - option.title?.toLowerCase().includes('annual'); - - return ( - - ); - })} -
- ) : ( -
- -
- )} - - {isCancelingSubscription && subscription?.current_period_end && ( -

- You can reactivate your current plan now. Plan changes are available - after {formatDate(subscription.current_period_end)}. -

- )} - - {/* Features List */} -
-

Features:

-
    - {defaultFeatures.map((feature, idx) => ( -
  • - - {feature} -
  • - ))} -
-
- - {/* Error Message */} - {error && ( -
- -

{error}

-
- )} - - {/* Primary Action Button */} - - - {/* Secondary Actions */} -
- - - {!isCancelingSubscription && ( - - )} -
- - )} -
- ) : ( - /* USAGE TAB */ -
- {/* Period Tabs */} -
- {periods.map((period) => ( - - ))} -
- - {/* Stats Summary - Compact Single Row */} - -
-
-
- -
-

- {usage ? formatDuration(usage.transcription_seconds) : '0m'} -

-

Listening

-
-
-
- -
-

- {usage ? formatNumber(usage.words_transcribed) : '0'} -

-

Words

-
-
-
- -
-

- {usage?.insights_gained || 0} -

-

Insights

-
-
-
- -
-

- {usage?.memories_created || 0} -

-

Memories

-
-
-
- - {/* Usage Trends Chart */} - -
- )} - - {/* Cancel Subscription Confirmation Dialog */} - setShowCancelConfirm(false)} - isDestructive={true} - isLoading={isCanceling} - /> -
- ); -} - -// ============================================================================ -// Developer Section -// ============================================================================ - -// Create API Key Dialog -function CreateApiKeyDialog({ - isOpen, - onClose, - onCreateKey, -}: { - isOpen: boolean; - onClose: () => void; - onCreateKey: (name: string, scopes: string[]) => Promise; -}) { - const [keyName, setKeyName] = useState(''); - const [scopes, setScopes] = useState>({ - 'conversations:read': false, - 'conversations:write': false, - 'memories:read': false, - 'memories:write': false, - 'action_items:read': false, - 'action_items:write': false, - }); - const [isCreating, setIsCreating] = useState(false); - const [createdKey, setCreatedKey] = useState(null); - const [copied, setCopied] = useState(false); - - const selectedScopes = Object.entries(scopes) - .filter(([, v]) => v) - .map(([k]) => k); - const isReadOnly = - scopes['conversations:read'] && - scopes['memories:read'] && - scopes['action_items:read'] && - !scopes['conversations:write'] && - !scopes['memories:write'] && - !scopes['action_items:write']; - const isFullAccess = Object.values(scopes).every((v) => v); - - const selectReadOnly = () => { - setScopes({ - 'conversations:read': true, - 'conversations:write': false, - 'memories:read': true, - 'memories:write': false, - 'action_items:read': true, - 'action_items:write': false, - }); - }; - - const selectFullAccess = () => { - setScopes(Object.fromEntries(Object.keys(scopes).map((k) => [k, true]))); - }; - - const handleCreate = async () => { - if (!keyName.trim()) return; - setIsCreating(true); - const key = await onCreateKey( - keyName.trim(), - selectedScopes.length > 0 ? selectedScopes : (undefined as unknown as string[]), - ); - if (key) { - setCreatedKey(key); - } - setIsCreating(false); - }; - - const handleCopy = () => { - if (createdKey?.key) { - navigator.clipboard.writeText(createdKey.key); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const handleClose = () => { - setKeyName(''); - setScopes(Object.fromEntries(Object.keys(scopes).map((k) => [k, false]))); - setCreatedKey(null); - setCopied(false); - onClose(); - }; - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - {createdKey ? ( -
-
-
- -
-
-

- API Key Created -

-

- Save this key now - you won't see it again! -

-
-
-
-

Your API Key

- - {createdKey.key} - -
-
- - -
-
- ) : ( -
-
-

Create API Key

- -
- -
-
- - setKeyName(e.target.value)} - placeholder="e.g., My App Integration" - className="w-full px-4 py-3 rounded-xl bg-bg-tertiary border border-white/[0.06] text-text-primary placeholder:text-text-quaternary focus:outline-none focus:border-white/25" - /> -
- -
-
- -
- - -
-
- -
- {['Conversations', 'Memories', 'Action Items'].map((resource) => { - const readKey = `${resource.toLowerCase().replace(' ', '_')}:read`; - const writeKey = `${resource.toLowerCase().replace(' ', '_')}:write`; - return ( -
- {resource} -
- - -
-
- ); - })} -
-

- R = Read, W = Write. Defaults to read-only if nothing selected. -

-
- - -
-
- )} -
-
- ); -} - -// Create MCP Key Dialog -function CreateMcpKeyDialog({ - isOpen, - onClose, - onCreateKey, -}: { - isOpen: boolean; - onClose: () => void; - onCreateKey: (name: string) => Promise; -}) { - const [keyName, setKeyName] = useState(''); - const [isCreating, setIsCreating] = useState(false); - const [createdKey, setCreatedKey] = useState(null); - const [copied, setCopied] = useState(false); - - const handleCreate = async () => { - if (!keyName.trim()) return; - setIsCreating(true); - const key = await onCreateKey(keyName.trim()); - if (key) { - setCreatedKey(key); - } - setIsCreating(false); - }; - - const handleCopy = () => { - if (createdKey?.key) { - navigator.clipboard.writeText(createdKey.key); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const handleClose = () => { - setKeyName(''); - setCreatedKey(null); - setCopied(false); - onClose(); - }; - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - {createdKey ? ( -
-
-
- -
-
-

- MCP Key Created -

-

- Save this key now - you won't see it again! -

-
-
-
-

Your MCP Key

- - {createdKey.key} - -
-
- - -
-
- ) : ( -
-
-

Create MCP Key

- -
-
-
- - setKeyName(e.target.value)} - placeholder="e.g., Claude Desktop" - className="w-full px-4 py-3 rounded-xl bg-bg-tertiary border border-white/[0.06] text-text-primary placeholder:text-text-quaternary focus:outline-none focus:border-white/25" - /> -
- -
-
- )} -
-
- ); -} - -function DeveloperSection({ - apiKeys, - mcpKeys, - webhooks, - onCreateApiKey, - onDeleteApiKey, - onCreateMcpKey, - onDeleteMcpKey, - onWebhookChange, - onExportData, - isExporting, - onDeleteKnowledgeGraph, -}: { - apiKeys: DeveloperApiKey[]; - mcpKeys: McpApiKey[]; - webhooks: DeveloperWebhooks; - onCreateApiKey: (name: string, scopes: string[]) => Promise; - onDeleteApiKey: (keyId: string) => void; - onCreateMcpKey: (name: string) => Promise; - onDeleteMcpKey: (keyId: string) => void; - onWebhookChange: (type: string, enabled: boolean, url?: string, delay?: string) => void; - onExportData: () => void; - isExporting?: boolean; - onDeleteKnowledgeGraph: () => void; -}) { - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [showMcpKeyDialog, setShowMcpKeyDialog] = useState(false); - const [showDeleteGraphDialog, setShowDeleteGraphDialog] = useState(false); - const [copiedConfig, setCopiedConfig] = useState(false); - const [copiedUrl, setCopiedUrl] = useState(false); - const [copiedClaudeName, setCopiedClaudeName] = useState(false); - const [copiedClaudeUrl, setCopiedClaudeUrl] = useState(false); - const [copiedClaudeClientId, setCopiedClaudeClientId] = useState(false); - const [copiedClaudeSecret, setCopiedClaudeSecret] = useState(false); - - const mcpServerUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.omi.me'}/v1/mcp/sse`; - - // Claude connector values — mirror the 4 fields in Claude's "Add custom connector" form - const claudeConnectorName = 'Omi Memory'; - const claudeConnectorUrl = mcpServerUrl; - const claudeConnectorClientId = CLAUDE_CONNECTOR_OAUTH.clientId; - const claudeConnectorSecret: string = CLAUDE_CONNECTOR_OAUTH.clientSecret; - - // Experimental features (stored in localStorage) - const [experimentalFeatures, setExperimentalFeatures] = useState({ - transcriptionDiagnostics: false, - autoCreateSpeakers: false, - followUpQuestions: false, - goalTracker: false, - }); - - // Load experimental features from localStorage on mount - useEffect(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('omi_experimental_features'); - if (saved) { - try { - setExperimentalFeatures(JSON.parse(saved)); - } catch { - // Ignore parse errors - } - } - } - }, []); - - // Save experimental features to localStorage when they change - const updateExperimentalFeature = ( - key: keyof typeof experimentalFeatures, - value: boolean, - ) => { - const updated = { ...experimentalFeatures, [key]: value }; - setExperimentalFeatures(updated); - if (typeof window !== 'undefined') { - localStorage.setItem('omi_experimental_features', JSON.stringify(updated)); - } - }; - - // Parse audio_bytes URL which may contain comma-separated URL and delay (e.g., "https://example.com,5") - const parseAudioBytesUrl = (rawUrl: string) => { - if (!rawUrl) return { url: '', delay: '5' }; - const parts = rawUrl.split(','); - if (parts.length >= 2) { - return { url: parts[0], delay: parts[1] }; - } - return { url: rawUrl, delay: '5' }; - }; - - const initialAudioBytes = parseAudioBytesUrl(webhooks.audio_bytes?.url || ''); - - const [webhookUrls, setWebhookUrls] = useState>({ - memory_created: webhooks.memory_created?.url || '', - transcript_received: webhooks.transcript_received?.url || '', - audio_bytes: initialAudioBytes.url, - day_summary: webhooks.day_summary?.url || '', - }); - const [audioBytesDelay, setAudioBytesDelay] = useState(initialAudioBytes.delay); - - // Update webhook URLs when webhooks prop changes - useEffect(() => { - const audioBytes = parseAudioBytesUrl(webhooks.audio_bytes?.url || ''); - setWebhookUrls({ - memory_created: webhooks.memory_created?.url || '', - transcript_received: webhooks.transcript_received?.url || '', - audio_bytes: audioBytes.url, - day_summary: webhooks.day_summary?.url || '', - }); - setAudioBytesDelay(audioBytes.delay); - }, [webhooks]); - - const webhookTypes = [ - { - id: 'memory_created', - label: 'Conversation Events', - description: 'New conversation created', - icon: MessageSquare, - }, - { - id: 'transcript_received', - label: 'Real-time Transcript', - description: 'Transcript received', - icon: FileText, - }, - { - id: 'audio_bytes', - label: 'Audio Bytes', - description: 'Audio data received', - icon: Radio, - hasDelay: true, - }, - { - id: 'day_summary', - label: 'Day Summary', - description: 'Summary generated', - icon: Calendar, - }, - ]; - - const claudeDesktopConfig = `{ - "mcpServers": { - "omi": { - "command": "docker", - "args": ["run", "--rm", "-i", "-e", "OMI_API_KEY=your_api_key_here", "omiai/mcp-server:latest"] - } - } -}`; - - const copyConfig = () => { - navigator.clipboard.writeText(claudeDesktopConfig); - setCopiedConfig(true); - setTimeout(() => setCopiedConfig(false), 2000); - }; - - const copyUrl = () => { - navigator.clipboard.writeText(mcpServerUrl); - setCopiedUrl(true); - setTimeout(() => setCopiedUrl(false), 2000); - }; - - return ( -
- {/* Developer API Keys */} -
-
-

- Developer API Keys -

- -
- - {apiKeys.length > 0 ? ( -
- {apiKeys.map((apiKey) => ( -
-
-
- - {apiKey.name} - - - {apiKey.key_prefix}... - - {apiKey.scopes && apiKey.scopes.length > 0 && ( - - {apiKey.scopes.length} scopes - - )} -
-

- Created {new Date(apiKey.created_at).toLocaleDateString()} - {apiKey.last_used_at && - ` • Last used ${new Date(apiKey.last_used_at).toLocaleDateString()}`} -

-
- -
- ))} -
- ) : ( -

- No API keys created yet -

- )} -
-
- - {/* MCP Section */} -
-
-
-

- MCP -

- - Docs ↗ - -
- -
- - {/* MCP Keys List */} - - {mcpKeys.length > 0 ? ( -
- {mcpKeys.map((key) => ( -
-
-
- - {key.name} - - - {key.key_prefix}... - -
-

- Created {new Date(key.created_at).toLocaleDateString()} - {key.last_used_at && - ` • Last used ${new Date(key.last_used_at).toLocaleDateString()}`} -

-
- -
- ))} -
- ) : ( -

- No MCP keys created yet -

- )} -
- - {/* Claude Desktop Config */} - -
-
- -
-
-

Claude Desktop

-

- Add to claude_desktop_config.json -

-
-
-
-
-              {claudeDesktopConfig}
-            
-
- -
- - {/* Generic MCP Server Info */} - -
-
- -
-
-

MCP Server

-

- Connect ChatGPT, Codex, Claude, or any MCP client to your data -

-
-
- -
-
-

- Server URL -

- -
- -
-

- API Key Auth -

-
- Header - - Authorization: Bearer <key> - -
-
- -
-

- OAuth -

-
-
- Client ID - omi -
-
- Client Secret - - Use your MCP API key - -
-
-
-
-
- - {/* Claude Connector — 4 copy fields mirroring Claude's "Add custom connector" form */} - -
-
- C -
-
-

Claude

-

Live MCP or memory pack

-
-
- -

- Connect over MCP so Claude reads your memories live, or copy a memory pack. - Each field below maps to Claude’s{' '} - - Settings → Connectors → Add custom connector - {' '} - form. -

- -
- {/* Field 1: Name → pastes into Claude's "Name" input */} -
-

- 1. Name{' '} - - → Claude "Name" - -

- -
- - {/* Field 2: Server URL → pastes into Claude's "Remote MCP server URL" input */} -
-

- 2. Remote MCP server URL{' '} - - → Claude "Remote MCP server URL" - -

- -
- - {/* Field 3: OAuth Client ID → pastes into Claude's Advanced "OAuth Client ID" */} -
-

- 3. OAuth Client ID{' '} - - → Claude Advanced "OAuth Client ID" - -

- -
- - {/* Field 4: OAuth Client Secret → pastes into Claude's Advanced "OAuth Client Secret" */} -
-

- 4. OAuth Client Secret{' '} - - → Claude Advanced "OAuth Client Secret" - -

- {claudeConnectorSecret ? ( - - ) : ( -
- Leave blank -
- )} -
-
- -
-
    -
  1. - Open{' '} - - claude.ai → Settings → Connectors → Add custom connector - -
  2. -
  3. - Click each Copy button above - and paste into the matching field -
  4. -
  5. - Under Advanced settings, - paste OAuth Client ID + Secret -
  6. -
  7. - Click Add, then{' '} - Connect -
  8. -
-
-
-
- - {/* Webhooks */} -
-
-

- Webhooks -

- - Docs ↗ - -
- -
- {webhookTypes.map((webhook, index) => { - const webhookData = webhooks[webhook.id as keyof DeveloperWebhooks]; - const isEnabled = webhookData?.enabled || false; - const Icon = webhook.icon; - - return ( -
- {index > 0 &&
} -
-
-
-
- -
-
-

- {webhook.label} -

-

- {webhook.description} -

-
-
- - onWebhookChange( - webhook.id, - enabled, - webhookUrls[webhook.id], - webhook.hasDelay ? audioBytesDelay : undefined, - ) - } - /> -
- {isEnabled && ( -
- - setWebhookUrls({ - ...webhookUrls, - [webhook.id]: e.target.value, - }) - } - onBlur={() => - onWebhookChange( - webhook.id, - true, - webhookUrls[webhook.id], - webhook.hasDelay ? audioBytesDelay : undefined, - ) - } - placeholder="https://your-server.com/webhook" - className="w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-white/[0.06] text-text-primary text-sm placeholder:text-text-quaternary focus:outline-none focus:border-white/25" - /> - {webhook.hasDelay && ( - setAudioBytesDelay(e.target.value)} - onBlur={() => - onWebhookChange( - webhook.id, - true, - webhookUrls[webhook.id], - audioBytesDelay, - ) - } - placeholder="Interval (seconds)" - className="w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-white/[0.06] text-text-primary text-sm placeholder:text-text-quaternary focus:outline-none focus:border-white/25" - /> - )} -
- )} -
-
- ); - })} -
- -
- - {/* Data Management */} -
-

- Data Management -

- - - - - - -
- - {/* Experimental Features */} -
-
-

- Experimental -

- -
- -
- {/* Transcription Diagnostics */} -
-
-
- -
-
-

- Transcription Diagnostics -

-

- Detailed diagnostic messages -

-
-
- updateExperimentalFeature('transcriptionDiagnostics', v)} - /> -
- - {/* Auto-create Speakers */} -
-
-
- -
-
-

- Auto-create Speakers -

-

- Auto-create when name detected -

-
-
- updateExperimentalFeature('autoCreateSpeakers', v)} - /> -
- - {/* Follow-up Questions */} -
-
-
- -
-
-

- Follow-up Questions -

-

- Suggest questions after conversations -

-
-
- updateExperimentalFeature('followUpQuestions', v)} - /> -
- - {/* Goal Tracker */} -
-
-
- -
-
-

Goal Tracker

-

- Track your personal goals on homepage -

-
-
- updateExperimentalFeature('goalTracker', v)} - /> -
-
-
-
- - {/* Links */} - - -
- - API Documentation -
- -
-
- - {/* Dialogs */} - setShowApiKeyDialog(false)} - onCreateKey={onCreateApiKey} - /> - setShowMcpKeyDialog(false)} - onCreateKey={onCreateMcpKey} - /> - - {/* Delete Knowledge Graph Dialog */} - {showDeleteGraphDialog && ( -
setShowDeleteGraphDialog(false)} - > -
e.stopPropagation()} - > -
-
- -
-

- Delete Knowledge Graph? -

-
-

- This will delete all derived knowledge graph data (nodes and connections). - Your original memories will remain safe. The graph will be rebuilt over - time. -

-
- - -
-
-
- )} -
- ); -} - -// ============================================================================ -// Account Section -// ============================================================================ - -function AccountSection({ - allUsage, - subscription, - cachedPlans, - onSubscriptionUpdate, - onSignOut, - onDeleteAccount, -}: { - allUsage: AllUsageData | null; - subscription: UserSubscription | null; - cachedPlans: PricingOption[] | null; - onSubscriptionUpdate: () => void; - onSignOut: () => void; - onDeleteAccount: () => void; -}) { - return ( -
- {/* Plan & Usage */} -
- -
- - {/* Fair Use */} -
- - -
- -
- Fair Use -

- View speech usage and policy status -

-
-
- - -
-
- - {/* Account Actions */} -
-

- Account Actions -

- - - - - - - -
- - {/* Support */} - -
- ); -} - -// ============================================================================ -// Main Settings Page Component -// ============================================================================ - -// Section titles and descriptions for the header - -export function SettingsPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { user, signOut } = useAuth(); - const { showToast } = useToast(); - const [isExporting, setIsExporting] = useState(false); - - // Get section from URL, default to 'account' - const sectionParam = searchParams.get('section'); - const activeSection: SettingsSection = - sectionParam && isSettingsSectionId(sectionParam) ? sectionParam : 'account'; - - // Track which sections have been loaded (using ref to avoid dependency issues) - const loadedSectionsRef = useRef>(new Set()); - const [sectionLoading, setSectionLoading] = useState(null); - - // Settings state - each section's data - const [language, setLanguage] = useState('en'); - const [vocabulary, setVocabulary] = useState([]); - const [dailySummary, setDailySummary] = useState({ - enabled: true, - hour: 22, - }); - const [recordingPermission, setRecordingPermissionState] = useState(false); - const [trainingDataOptIn, setTrainingDataOptInState] = useState(false); - const [allUsage, setAllUsage] = useState(null); - const [subscription, setSubscription] = useState(null); - const [cachedPlans, setCachedPlans] = useState(null); - const [apiKeys, setApiKeys] = useState([]); - const [mcpKeys, setMcpKeys] = useState([]); - const [webhooks, setWebhooks] = useState({}); - - // Dialog states - const [showSignOutDialog, setShowSignOutDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - // Load section data on demand - useEffect(() => { - const section = activeSection; - if (loadedSectionsRef.current.has(section)) return; - - const loadSectionData = async () => { - setSectionLoading(section); - try { - switch (section) { - case 'privacy': - const [recording, training] = await Promise.all([ - getRecordingPermission().catch(() => ({ enabled: false })), - getTrainingDataOptIn().catch(() => ({ opted_in: false })), - ]); - setRecordingPermissionState(recording.enabled); - setTrainingDataOptInState(training.opted_in); - break; - case 'account': - // The merged Account section renders the former Profile and Account - // groups together, so it loads both sets in one pass. - const [lang, vocab, summary, usageData, sub, plansData] = await Promise.all([ - getUserLanguage().catch(() => 'en'), - getCustomVocabulary().catch(() => []), - getDailySummarySettings().catch(() => ({ enabled: true, hour: 22 })), - getAllUsageData().catch(() => null), - getUserSubscription().catch(() => null), - getAvailablePlans().catch(() => null), - ]); - setLanguage(lang); - setVocabulary(vocab); - setDailySummary(summary); - setAllUsage(usageData); - setSubscription(sub); - if (plansData?.plans) { - setCachedPlans(plansData.plans); - } - break; - case 'developer': - // Fetch API keys, MCP keys, webhook status, and individual webhook URLs in parallel - // Note: Status API returns boolean fields, URL API returns {url: string} - const [ - keys, - mKeys, - webhookStatus, - memoryUrl, - transcriptUrl, - audioBytesUrl, - daySummaryUrl, - ] = await Promise.all([ - getDeveloperApiKeys().catch(() => []), - getMcpApiKeys().catch(() => []), - getDeveloperWebhooksStatus().catch(() => ({})), - getDeveloperWebhook('memory_created').catch(() => ({ url: '' })), - getDeveloperWebhook('realtime_transcript').catch(() => ({ url: '' })), - getDeveloperWebhook('audio_bytes').catch(() => ({ url: '' })), - getDeveloperWebhook('day_summary').catch(() => ({ url: '' })), - ]); - setApiKeys(keys); - // Full MCP key is only returned at creation time; keep secrets in memory for this session only. - if (typeof window !== 'undefined') { - localStorage.removeItem('omi_mcp_api_key_secrets'); - } - setMcpKeys(mKeys); - // Combine status (booleans) with URLs - const statusMap = webhookStatus as Record; - setWebhooks({ - memory_created: { - url: memoryUrl?.url || '', - enabled: statusMap['memory_created'] ?? false, - }, - transcript_received: { - url: transcriptUrl?.url || '', - enabled: statusMap['realtime_transcript'] ?? false, - }, - audio_bytes: { - url: audioBytesUrl?.url || '', - enabled: statusMap['audio_bytes'] ?? false, - }, - day_summary: { - url: daySummaryUrl?.url || '', - enabled: statusMap['day_summary'] ?? false, - }, - }); - break; - // sections not listed here don't need API calls - } - loadedSectionsRef.current.add(section); - } catch (error) { - console.error(`Failed to load ${section} settings:`, error); - } finally { - setSectionLoading(null); - } - }; - - loadSectionData(); - }, [activeSection]); - - // Handlers - const handleLanguageChange = async (newLanguage: string) => { - const oldLanguage = language; - setLanguage(newLanguage); - try { - await setUserLanguage(newLanguage); - } catch { - setLanguage(oldLanguage); - } - }; - - const handleAddWord = async (word: string) => { - const newVocabulary = [...vocabulary, word]; - setVocabulary(newVocabulary); - try { - await updateCustomVocabulary(newVocabulary); - } catch { - setVocabulary(vocabulary); - } - }; - - const handleRemoveWord = async (word: string) => { - const newVocabulary = vocabulary.filter((w) => w !== word); - setVocabulary(newVocabulary); - try { - await updateCustomVocabulary(newVocabulary); - } catch { - setVocabulary(vocabulary); - } - }; - - const handleDailySummaryToggle = async (enabled: boolean) => { - const oldSettings = dailySummary; - setDailySummary({ ...dailySummary, enabled }); - try { - await updateDailySummarySettings({ ...dailySummary, enabled }); - } catch { - setDailySummary(oldSettings); - } - }; - - const handleDailySummaryHourChange = async (hour: number) => { - const oldSettings = dailySummary; - setDailySummary({ ...dailySummary, hour }); - try { - await updateDailySummarySettings({ ...dailySummary, hour }); - } catch { - setDailySummary(oldSettings); - } - }; - - const handleRecordingPermissionChange = async (enabled: boolean) => { - const oldValue = recordingPermission; - setRecordingPermissionState(enabled); - try { - await setRecordingPermission(enabled); - } catch { - setRecordingPermissionState(oldValue); - } - }; - - const handleTrainingDataChange = async (optIn: boolean) => { - const oldValue = trainingDataOptIn; - setTrainingDataOptInState(optIn); - try { - await setTrainingDataOptIn(optIn); - } catch { - setTrainingDataOptInState(oldValue); - } - }; - - const refreshSubscription = async () => { - try { - const [usageData, sub] = await Promise.all([ - getAllUsageData().catch(() => null), - getUserSubscription().catch(() => null), - ]); - setAllUsage(usageData); - setSubscription(sub); - } catch (error) { - console.error('Failed to refresh subscription:', error); - } - }; - - const handleCopyUserId = () => { - if (user?.uid) { - navigator.clipboard.writeText(user.uid); - } - }; - - const handleCreateApiKey = async ( - name: string, - scopes: string[], - ): Promise => { - try { - const newKey = await createDeveloperApiKey(name, scopes); - setApiKeys([...apiKeys, newKey]); - return newKey; - } catch (error) { - console.error('Failed to create API key:', error); - return null; - } - }; - - const handleDeleteApiKey = async (keyId: string) => { - try { - await deleteDeveloperApiKey(keyId); - setApiKeys(apiKeys.filter((k) => k.id !== keyId)); - } catch (error) { - console.error('Failed to delete API key:', error); - } - }; - - const handleCreateMcpKey = async (name: string): Promise => { - try { - const newKey = await createMcpApiKey(name); - setMcpKeys([...mcpKeys, newKey]); - return newKey; - } catch (error) { - console.error('Failed to create MCP key:', error); - return null; - } - }; - - const handleDeleteMcpKey = async (keyId: string) => { - try { - await deleteMcpApiKey(keyId); - setMcpKeys(mcpKeys.filter((k) => k.id !== keyId)); - } catch (error) { - console.error('Failed to delete MCP key:', error); - } - }; - - const handleExportData = async () => { - if (isExporting) return; - setIsExporting(true); - try { - const blob = await exportAllData(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'omi-export.json'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showToast('Data exported successfully', 'success'); - } catch (error) { - console.error('Failed to export data:', error); - showToast('Failed to export data. Please try again.', 'error'); - } finally { - setIsExporting(false); - } - }; - - const handleDeleteKnowledgeGraph = async () => { - try { - await deleteKnowledgeGraph(); - } catch (error) { - console.error('Failed to delete knowledge graph:', error); - } - }; - - const handleWebhookChange = async ( - type: string, - enabled: boolean, - url?: string, - delay?: string, - ) => { - // Convert internal type names to API type names - // UI uses 'transcript_received' but API expects 'realtime_transcript' - const apiType = type === 'transcript_received' ? 'realtime_transcript' : type; - const webhookType = apiType as - 'memory_created' | 'realtime_transcript' | 'audio_bytes' | 'day_summary'; - try { - // For audio_bytes, combine URL and delay if both are provided - const webhookUrl = type === 'audio_bytes' && url && delay ? `${url},${delay}` : url; - if (webhookUrl) { - await setDeveloperWebhook(webhookType, webhookUrl); - } - if (enabled) { - await enableDeveloperWebhook(webhookType); - } else { - await disableDeveloperWebhook(webhookType); - } - setWebhooks({ - ...webhooks, - [type]: { - enabled, - url: url || webhooks[type as keyof DeveloperWebhooks]?.url || '', - }, - }); - } catch (error) { - console.error('Failed to update webhook:', error); - } - }; - - const handleSignOut = async () => { - await signOut(); - router.push(SIGNED_OUT_DESTINATION); - }; - - const handleDeleteAccount = async () => { - setIsDeleting(true); - try { - await deleteAccount(); - await signOut(); - router.push(SIGNED_OUT_DESTINATION); - } catch { - setIsDeleting(false); - } - }; - - const renderSection = () => { - // Show loading spinner when section is loading - if (sectionLoading === activeSection) { - return ( -
- -
- ); - } - - switch (activeSection) { - case 'privacy': - return ( - - ); - case 'account': - return ( -
- - setShowSignOutDialog(true)} - onDeleteAccount={() => setShowDeleteDialog(true)} - /> -
- ); - case 'developer': - return ( - - ); - default: - return null; - } - }; - - const sectionInfo = SECTION_INFO[activeSection]; - - // Quick nav sections for each settings section - const getQuickNavSections = () => { - switch (activeSection) { - case 'account': - return [ - { id: 'account-info', label: 'Account' }, - { id: 'language', label: 'Language' }, - { id: 'vocabulary', label: 'Vocabulary' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'plan-usage', label: 'Plan & Usage' }, - { id: 'fair-use', label: 'Fair Use' }, - { id: 'actions', label: 'Actions' }, - { id: 'support', label: 'Support' }, - ]; - case 'developer': - return [ - { id: 'api-keys', label: 'API Keys' }, - { id: 'mcp', label: 'MCP' }, - { id: 'webhooks', label: 'Webhooks' }, - { id: 'data-management', label: 'Data' }, - { id: 'experimental', label: 'Experimental' }, - ]; - default: - return []; - } - }; - - const quickNavSections = getQuickNavSections(); - - return ( -
- {/* Export in-progress dialog */} - {isExporting && ( -
-
-
-
-
- -
-
-

- Exporting Your Data -

-

- This may take a moment depending on the amount of data in your account. -

-
-
- - - Please don't close this tab - -
-
-
-
- )} - - {/* Page Header */} - - - {/* Main Content with optional Quick Nav */} -
-
-
- {/* Main content */} -
{renderSection()}
- - {/* Quick Nav Sidebar - only show on desktop when there are sections */} - {quickNavSections.length > 0 && ( -
-
-

- On this page -

- -
-
- )} -
-
-
- - {/* Dialogs */} - setShowSignOutDialog(false)} - /> - - setShowDeleteDialog(false)} - isDestructive - isLoading={isDeleting} - /> -
- ); -} diff --git a/web/app/src/features/auth/index.ts b/web/app/src/features/auth/index.ts new file mode 100644 index 00000000000..a4a16fc034a --- /dev/null +++ b/web/app/src/features/auth/index.ts @@ -0,0 +1,3 @@ +export { AuthProvider, useAuth } from './ui/AuthProvider'; +export { ProtectedRoute } from './ui/ProtectedRoute'; +export { LoginPanel } from './ui/LoginPanel'; diff --git a/web/app/src/components/auth/AuthProvider.tsx b/web/app/src/features/auth/ui/AuthProvider.tsx similarity index 100% rename from web/app/src/components/auth/AuthProvider.tsx rename to web/app/src/features/auth/ui/AuthProvider.tsx diff --git a/web/app/src/components/auth/LoginPanel.tsx b/web/app/src/features/auth/ui/LoginPanel.tsx similarity index 100% rename from web/app/src/components/auth/LoginPanel.tsx rename to web/app/src/features/auth/ui/LoginPanel.tsx diff --git a/web/app/src/components/auth/ProtectedRoute.tsx b/web/app/src/features/auth/ui/ProtectedRoute.tsx similarity index 100% rename from web/app/src/components/auth/ProtectedRoute.tsx rename to web/app/src/features/auth/ui/ProtectedRoute.tsx diff --git a/web/app/src/features/chat/ARCHITECTURE.md b/web/app/src/features/chat/ARCHITECTURE.md new file mode 100644 index 00000000000..bd7ce27f13b --- /dev/null +++ b/web/app/src/features/chat/ARCHITECTURE.md @@ -0,0 +1,8 @@ +# chat + +Not a rail destination. Lives on `/home` and as an overlay from other pages. + +- `api.ts` — sessions, streaming messages, file upload, Gemini live tokens +- `model.ts` — stream-line decode (`parseStreamLine`) +- `ui/` — `ChatComposer`/`ChatTranscript` (home) and `ChatPanel` (overlay); do not merge those composers in this folder move +- `useChat.ts` / `useGeminiLive.ts` — existing React orchestration diff --git a/web/app/src/lib/__tests__/chatSessionRouting.test.ts b/web/app/src/features/chat/__tests__/chatSessionRouting.test.ts similarity index 99% rename from web/app/src/lib/__tests__/chatSessionRouting.test.ts rename to web/app/src/features/chat/__tests__/chatSessionRouting.test.ts index db4e9626c6a..84af4918a32 100644 --- a/web/app/src/lib/__tests__/chatSessionRouting.test.ts +++ b/web/app/src/features/chat/__tests__/chatSessionRouting.test.ts @@ -12,7 +12,7 @@ const { createGeminiLiveSession, saveRealtimeMessage, reportGeminiLiveUsage, -} = await import('@/lib/api'); +} = await import('@/features/chat/api'); /** * Every `/v2/messages` call must carry the session the reader has selected. diff --git a/web/app/src/features/chat/__tests__/parseStreamLine.test.ts b/web/app/src/features/chat/__tests__/parseStreamLine.test.ts new file mode 100644 index 00000000000..b17cc8c1a67 --- /dev/null +++ b/web/app/src/features/chat/__tests__/parseStreamLine.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { parseStreamLine } from '../model'; + +describe('parseStreamLine', () => { + it('parses think and data prefixes', () => { + expect(parseStreamLine('think: hello')).toEqual({ type: 'think', text: 'hello' }); + expect(parseStreamLine('data: world')).toEqual({ type: 'data', text: 'world' }); + }); + + it('returns null for a blank line', () => { + expect(parseStreamLine('')).toBeNull(); + expect(parseStreamLine(' ')).toBeNull(); + }); +}); diff --git a/web/app/src/hooks/__tests__/useChat.test.tsx b/web/app/src/features/chat/__tests__/useChat.test.tsx similarity index 97% rename from web/app/src/hooks/__tests__/useChat.test.tsx rename to web/app/src/features/chat/__tests__/useChat.test.tsx index 5193a4a8bdc..aca451b70e3 100644 --- a/web/app/src/hooks/__tests__/useChat.test.tsx +++ b/web/app/src/features/chat/__tests__/useChat.test.tsx @@ -2,15 +2,15 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { MessageFile } from '@/types/conversation'; -vi.mock('@/lib/api', () => ({ +vi.mock('@/features/chat/api', () => ({ getMessages: vi.fn(), sendMessageStream: vi.fn(), clearMessages: vi.fn(), saveRealtimeMessage: vi.fn(), })); -const { getMessages, sendMessageStream, saveRealtimeMessage } = await import('@/lib/api'); -const { useChat } = await import('@/hooks/useChat'); +const { getMessages, sendMessageStream, saveRealtimeMessage } = await import('@/features/chat/api'); +const { useChat } = await import('@/features/chat/useChat'); type Deferred = { promise: Promise; resolve: (value: T) => void }; diff --git a/web/app/src/hooks/__tests__/useGeminiLive.test.tsx b/web/app/src/features/chat/__tests__/useGeminiLive.test.tsx similarity index 94% rename from web/app/src/hooks/__tests__/useGeminiLive.test.tsx rename to web/app/src/features/chat/__tests__/useGeminiLive.test.tsx index f0060800deb..309c7fe7d79 100644 --- a/web/app/src/hooks/__tests__/useGeminiLive.test.tsx +++ b/web/app/src/features/chat/__tests__/useGeminiLive.test.tsx @@ -14,7 +14,7 @@ const doubles = vi.hoisted(() => ({ }>, })); -vi.mock('@/lib/api', () => ({ +vi.mock('@/features/chat/api', () => ({ createGeminiLiveSession: vi.fn(async () => ({ token: 'session-token' })), reportGeminiLiveUsage: vi.fn(async () => undefined), })); @@ -37,7 +37,7 @@ vi.mock('@/lib/geminiLive', () => ({ }, })); -const { useGeminiLive } = await import('@/hooks/useGeminiLive'); +const { useGeminiLive } = await import('@/features/chat/useGeminiLive'); beforeEach(() => { vi.clearAllMocks(); diff --git a/web/app/src/features/chat/api.ts b/web/app/src/features/chat/api.ts new file mode 100644 index 00000000000..9411449bbb1 --- /dev/null +++ b/web/app/src/features/chat/api.ts @@ -0,0 +1,387 @@ +import { getIdToken } from '@/lib/firebase'; +import { getWebDeviceIdHash } from '@/lib/clientDevice'; +import { + invalidateCache, + invalidationPatterns, + fetchWithCache, + cacheKeys, + CACHE_TTL, +} from '@/lib/cache'; +import { + API_BASE_URL, + fetchAuthorizedBlob, + fetchWithAuth, + getAudioAuthHeaders, +} from '@/shared/api/client'; +import type { ServerMessage, MessageChunk, MessageFile } from '@/types/conversation'; +import type { ChatSession } from '@/types/chatSessions'; +import { parseStreamLine } from './model'; +export { parseStreamLine }; + +interface ChatSessionWire { + id: string; + title?: string | null; + preview?: string | null; + created_at: string; + updated_at: string; + app_id?: string | null; + message_count?: number | null; + starred?: boolean | null; +} + +function toChatSession(wire: ChatSessionWire): ChatSession { + return { + id: wire.id, + title: wire.title ?? undefined, + preview: wire.preview ?? undefined, + createdAt: wire.created_at, + updatedAt: wire.updated_at, + appId: wire.app_id ?? undefined, + messageCount: wire.message_count ?? 0, + starred: Boolean(wire.starred), + }; +} + +export async function getChatSessions(appId?: string): Promise { + const query = appId ? `?app_id=${encodeURIComponent(appId)}` : ''; + const sessions = await fetchWithAuth(`/v2/chat-sessions${query}`); + return Array.isArray(sessions) ? sessions.map(toChatSession) : []; +} + +export async function createChatSession( + params: { title?: string; app_id?: string } = {}, +): Promise { + return toChatSession( + await fetchWithAuth('/v2/chat-sessions', { + method: 'POST', + body: JSON.stringify(params), + }), + ); +} + +export async function updateChatSession( + id: string, + updates: { title?: string; starred?: boolean }, +): Promise { + return toChatSession( + await fetchWithAuth(`/v2/chat-sessions/${id}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }), + ); +} + +export async function deleteChatSession(id: string): Promise { + await fetchWithAuth(`/v2/chat-sessions/${id}`, { method: 'DELETE' }); +} + +export interface RealtimeSessionToken { + provider: 'gemini'; + token: string; + expires_at?: string; +} + +export interface RealtimeUsageReport { + input_text_tokens: number; + input_audio_tokens: number; + input_cached_tokens: number; + output_text_tokens: number; + output_audio_tokens: number; +} + +interface SavedRealtimeMessage { + id: string; + created_at: string; + session_id?: string | null; +} + +export async function createGeminiLiveSession(): Promise { + return fetchWithAuth('/v2/realtime/session', { + method: 'POST', + body: JSON.stringify({ provider: 'gemini' }), + }); +} + +export async function saveRealtimeMessage(params: { + text: string; + sender: 'human' | 'ai'; + clientMessageId: string; + appId?: string; + sessionId?: string | null; +}): Promise { + return fetchWithAuth('/v2/desktop/messages', { + method: 'POST', + body: JSON.stringify({ + text: params.text, + sender: params.sender, + app_id: params.appId, + session_id: params.sessionId, + client_message_id: params.clientMessageId, + message_source: 'realtime_voice', + }), + }); +} + +export async function reportGeminiLiveUsage(usage: RealtimeUsageReport): Promise { + await fetchWithAuth('/v2/realtime/usage', { + method: 'POST', + body: JSON.stringify({ + provider: 'gemini', + model: 'gemini-3.1-flash-live-preview', + ...usage, + }), + }); +} + +/** + * Get message history + */ +export async function getMessages( + appId?: string, + chatSessionId?: string | null, +): Promise { + const queryParams = new URLSearchParams(); + if (appId) { + queryParams.set('app_id', appId); + } + // Omitted entirely for the default shared thread; naming a session targets + // that one specific thread. + if (chatSessionId) { + queryParams.set('chat_session_id', chatSessionId); + } + + const endpoint = `/v2/messages${queryParams.toString() ? `?${queryParams}` : ''}`; + return fetchWithAuth(endpoint); +} + +/** + * Send a message with streaming response + */ +export async function sendMessageStream( + text: string, + onChunk: (chunk: MessageChunk) => void, + options?: { + appId?: string; + /** Target one specific thread; omit for the default shared thread. */ + chatSessionId?: string | null; + fileIds?: string[]; + context?: { + type: string; + id?: string; + title?: string; + summary?: string; + } | null; + }, +): Promise { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const queryParams = new URLSearchParams(); + if (options?.appId) { + queryParams.set('app_id', options.appId); + } + // Without this the reply is persisted to the default shared thread while the + // UI shows it under the selected one. + if (options?.chatSessionId) { + queryParams.set('chat_session_id', options.chatSessionId); + } + + const url = `${API_BASE_URL}/v2/messages${queryParams.toString() ? `?${queryParams}` : ''}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-App-Platform': 'web', + }, + body: JSON.stringify({ + text, + file_ids: options?.fileIds || [], + context: options?.context || null, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Send message error:', response.status, errorText); + throw new Error(`Failed to send message: ${response.status}`); + } + + if (!response.body) { + throw new Error('No response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + const chunk = parseStreamLine(line); + if (chunk) { + onChunk(chunk); + } + } + } + + // Process any remaining buffer + if (buffer) { + const chunk = parseStreamLine(buffer); + if (chunk) { + onChunk(chunk); + } + } + } finally { + reader.releaseLock(); + } +} + +/** + * Clear message history + */ +export async function clearMessages( + appId?: string, + chatSessionId?: string | null, +): Promise { + const queryParams = new URLSearchParams(); + if (appId) { + queryParams.set('app_id', appId); + } + // Clearing must delete the thread the reader is looking at, not the shared one. + if (chatSessionId) { + queryParams.set('chat_session_id', chatSessionId); + } + + const endpoint = `/v2/messages${queryParams.toString() ? `?${queryParams}` : ''}`; + await fetchWithAuth(endpoint, { + method: 'DELETE', + }); +} + +/** + * Upload files for chat + */ +export async function uploadChatFiles( + files: File[], + appId?: string, +): Promise { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const queryParams = new URLSearchParams(); + if (appId) { + queryParams.set('app_id', appId); + } + + const url = `${API_BASE_URL}/v2/files${queryParams.toString() ? `?${queryParams}` : ''}`; + + const formData = new FormData(); + for (const file of files) { + // Append with explicit filename to ensure proper handling + formData.append('files', file, file.name); + } + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Upload files error:', response.status, errorText); + throw new Error(`Failed to upload files: ${response.status}`); + } + + return response.json(); +} + +/** + * Transcribe voice message to text + */ +function getAudioFileExtension(mimeType: string): string { + const normalizedMimeType = mimeType.split(';', 1)[0].toLowerCase(); + if (normalizedMimeType === 'audio/webm' || normalizedMimeType === 'video/webm') + return 'webm'; + if (normalizedMimeType === 'audio/mp4' || normalizedMimeType === 'video/mp4') + return 'mp4'; + return 'wav'; +} + +export async function transcribeVoiceMessage(audioBlob: Blob): Promise { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const url = `${API_BASE_URL}/v2/voice-message/transcribe`; + + const formData = new FormData(); + // The backend uses the filename extension when it uploads audio for STT. + formData.append('files', audioBlob, `audio.${getAudioFileExtension(audioBlob.type)}`); + const deviceIdHash = await getWebDeviceIdHash(); + const headers: HeadersInit = { + Authorization: `Bearer ${token}`, + 'X-App-Platform': 'web', + }; + if (deviceIdHash) { + headers['X-Device-Id-Hash'] = deviceIdHash; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Transcribe error:', response.status, errorText); + throw new Error(`Failed to transcribe audio: ${response.status}`); + } + + const data = await response.json(); + return data.transcript || ''; +} diff --git a/web/app/src/features/chat/index.ts b/web/app/src/features/chat/index.ts new file mode 100644 index 00000000000..ad53eb1e40f --- /dev/null +++ b/web/app/src/features/chat/index.ts @@ -0,0 +1,9 @@ +export { ChatProvider, useChat } from './ui/ChatContext'; +export { ChatPanel } from './ui/ChatPanel'; +export { ChatBubble } from './ui/ChatBubble'; +export { ChatComposer, type ChatComposerHandle } from './ui/ChatComposer'; +export { ChatTranscript } from './ui/ChatTranscript'; +export { RecordingStage } from './ui/RecordingStage'; +export { useGeminiLive } from './useGeminiLive'; +export { useChat as useChatSession } from './useChat'; +export * from './api'; diff --git a/web/app/src/features/chat/model.ts b/web/app/src/features/chat/model.ts new file mode 100644 index 00000000000..3c9f39ca717 --- /dev/null +++ b/web/app/src/features/chat/model.ts @@ -0,0 +1,79 @@ +import type { ServerMessage, MessageChunk, MessageChunkType } from '@/types/conversation'; + +/** + * Decode base64 string to UTF-8 text + */ +function decodeBase64Utf8(base64: string): string { + try { + // Decode base64 to binary string + const binaryString = atob(base64); + // Convert binary string to Uint8Array + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + // Decode as UTF-8 + const decoder = new TextDecoder('utf-8'); + return decoder.decode(bytes); + } catch (e) { + console.error('Failed to decode base64 UTF-8:', e); + // Fallback to simple atob + return atob(base64); + } +} + +/** + * Parse a streaming response line into a MessageChunk + */ +export function parseStreamLine(line: string): MessageChunk | null { + if (!line || line.trim() === '') return null; + + if (line.startsWith('think: ')) { + return { + type: 'think' as MessageChunkType, + text: line.slice(7).replace(/__CRLF__/g, '\n'), + }; + } + if (line.startsWith('data: ')) { + return { + type: 'data' as MessageChunkType, + text: line.slice(6).replace(/__CRLF__/g, '\n'), + }; + } + if (line.startsWith('done: ')) { + try { + const decoded = decodeBase64Utf8(line.slice(6)); + const message = JSON.parse(decoded) as ServerMessage; + return { + type: 'done' as MessageChunkType, + text: decoded, + message, + }; + } catch (e) { + console.error('Failed to parse done chunk:', e); + return null; + } + } + if (line.startsWith('message: ')) { + try { + const decoded = decodeBase64Utf8(line.slice(9)); + const message = JSON.parse(decoded) as ServerMessage; + return { + type: 'message' as MessageChunkType, + text: decoded, + message, + }; + } catch (e) { + console.error('Failed to parse message chunk:', e); + return null; + } + } + if (line.startsWith('error: ')) { + return { + type: 'error' as MessageChunkType, + text: line.slice(7), + }; + } + + return null; +} diff --git a/web/app/src/components/chat/ChatBubble.tsx b/web/app/src/features/chat/ui/ChatBubble.tsx similarity index 100% rename from web/app/src/components/chat/ChatBubble.tsx rename to web/app/src/features/chat/ui/ChatBubble.tsx diff --git a/web/app/src/components/chat/ChatComposer.tsx b/web/app/src/features/chat/ui/ChatComposer.tsx similarity index 99% rename from web/app/src/components/chat/ChatComposer.tsx rename to web/app/src/features/chat/ui/ChatComposer.tsx index 3b93397cb10..44e27e2f048 100644 --- a/web/app/src/components/chat/ChatComposer.tsx +++ b/web/app/src/features/chat/ui/ChatComposer.tsx @@ -5,7 +5,7 @@ import { ArrowUp, Paperclip } from 'lucide-react'; import { FilePreview, ALLOWED_EXTENSIONS, MAX_FILES } from './FilePreview'; import { InlineVoiceRecorder } from './VoiceRecorder'; import { OmiPulseMark } from '@/components/ui/OmiPulseMark'; -import { uploadChatFiles } from '@/lib/api'; +import { uploadChatFiles } from '@/features/chat/api'; import type { MessageFile } from '@/types/conversation'; import { cn } from '@/lib/utils'; diff --git a/web/app/src/components/chat/ChatContext.tsx b/web/app/src/features/chat/ui/ChatContext.tsx similarity index 98% rename from web/app/src/components/chat/ChatContext.tsx rename to web/app/src/features/chat/ui/ChatContext.tsx index 806b7e9bd58..14f7195270b 100644 --- a/web/app/src/components/chat/ChatContext.tsx +++ b/web/app/src/features/chat/ui/ChatContext.tsx @@ -8,7 +8,7 @@ import { useMemo, ReactNode, } from 'react'; -import { useChat as useChatSession } from '@/hooks/useChat'; +import { useChat as useChatSession } from '@/features/chat/useChat'; type SharedChat = ReturnType; diff --git a/web/app/src/components/chat/ChatMarkdown.tsx b/web/app/src/features/chat/ui/ChatMarkdown.tsx similarity index 100% rename from web/app/src/components/chat/ChatMarkdown.tsx rename to web/app/src/features/chat/ui/ChatMarkdown.tsx diff --git a/web/app/src/components/chat/ChatPanel.tsx b/web/app/src/features/chat/ui/ChatPanel.tsx similarity index 99% rename from web/app/src/components/chat/ChatPanel.tsx rename to web/app/src/features/chat/ui/ChatPanel.tsx index a4e4b6ede0a..1fcaed550c3 100644 --- a/web/app/src/components/chat/ChatPanel.tsx +++ b/web/app/src/features/chat/ui/ChatPanel.tsx @@ -7,8 +7,9 @@ import { useChat as useChatContext } from './ChatContext'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { FilePreview, ALLOWED_EXTENSIONS, MAX_FILES } from './FilePreview'; import { InlineVoiceRecorder } from './VoiceRecorder'; -import { uploadChatFiles, getChatApps } from '@/lib/api'; -import type { App } from '@/lib/api'; +import { uploadChatFiles } from '@/features/chat/api'; +import { getChatApps } from '@/features/connectors'; +import type { App } from '@/features/connectors'; import { cn } from '@/lib/utils'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { ChatMarkdown } from './ChatMarkdown'; diff --git a/web/app/src/components/chat/ChatTranscript.tsx b/web/app/src/features/chat/ui/ChatTranscript.tsx similarity index 100% rename from web/app/src/components/chat/ChatTranscript.tsx rename to web/app/src/features/chat/ui/ChatTranscript.tsx diff --git a/web/app/src/components/chat/FilePreview.tsx b/web/app/src/features/chat/ui/FilePreview.tsx similarity index 100% rename from web/app/src/components/chat/FilePreview.tsx rename to web/app/src/features/chat/ui/FilePreview.tsx diff --git a/web/app/src/components/chat/RecordingStage.tsx b/web/app/src/features/chat/ui/RecordingStage.tsx similarity index 98% rename from web/app/src/components/chat/RecordingStage.tsx rename to web/app/src/features/chat/ui/RecordingStage.tsx index 81c04fd7aea..7e8a3d79ea3 100644 --- a/web/app/src/components/chat/RecordingStage.tsx +++ b/web/app/src/features/chat/ui/RecordingStage.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; import { Pause, Play, Square } from 'lucide-react'; -import type { TranscriptSegment } from '@/components/recording/RecordingContext'; +import type { TranscriptSegment } from '@/features/recording'; import { OmiPulseMark } from '@/components/ui/OmiPulseMark'; import { cn } from '@/lib/utils'; diff --git a/web/app/src/components/chat/VoiceRecorder.tsx b/web/app/src/features/chat/ui/VoiceRecorder.tsx similarity index 99% rename from web/app/src/components/chat/VoiceRecorder.tsx rename to web/app/src/features/chat/ui/VoiceRecorder.tsx index 0f3e0b9d2e6..e8b7972f1b9 100644 --- a/web/app/src/components/chat/VoiceRecorder.tsx +++ b/web/app/src/features/chat/ui/VoiceRecorder.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { Mic, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { transcribeVoiceMessage } from '@/lib/api'; +import { transcribeVoiceMessage } from '@/features/chat/api'; type RecordingState = 'idle' | 'recording' | 'transcribing'; diff --git a/web/app/src/components/chat/__tests__/ChatComposer.test.tsx b/web/app/src/features/chat/ui/__tests__/ChatComposer.test.tsx similarity index 97% rename from web/app/src/components/chat/__tests__/ChatComposer.test.tsx rename to web/app/src/features/chat/ui/__tests__/ChatComposer.test.tsx index ac975f07584..7b26645debf 100644 --- a/web/app/src/components/chat/__tests__/ChatComposer.test.tsx +++ b/web/app/src/features/chat/ui/__tests__/ChatComposer.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatComposer } from '@/components/chat/ChatComposer'; +import { ChatComposer } from '@/features/chat/ui/ChatComposer'; import type { MessageFile } from '@/types/conversation'; const reducedMotion = vi.hoisted(() => ({ value: false })); @@ -10,12 +10,12 @@ vi.mock('framer-motion', async (importOriginal) => ({ useReducedMotion: () => reducedMotion.value, })); -vi.mock('@/lib/api', () => ({ +vi.mock('@/features/chat/api', () => ({ transcribeVoiceMessage: vi.fn(), uploadChatFiles: vi.fn(), })); -const { uploadChatFiles } = await import('@/lib/api'); +const { uploadChatFiles } = await import('@/features/chat/api'); type Deferred = { promise: Promise; resolve: (value: T) => void }; diff --git a/web/app/src/components/chat/__tests__/ChatMarkdown.test.ts b/web/app/src/features/chat/ui/__tests__/ChatMarkdown.test.ts similarity index 92% rename from web/app/src/components/chat/__tests__/ChatMarkdown.test.ts rename to web/app/src/features/chat/ui/__tests__/ChatMarkdown.test.ts index 5ef5494c964..6e60bd92487 100644 --- a/web/app/src/components/chat/__tests__/ChatMarkdown.test.ts +++ b/web/app/src/features/chat/ui/__tests__/ChatMarkdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ChatMarkdown } from '@/components/chat/ChatMarkdown'; +import { ChatMarkdown } from '@/features/chat/ui/ChatMarkdown'; import { Streamdown } from 'streamdown'; describe('ChatMarkdown', () => { diff --git a/web/app/src/components/chat/__tests__/RecordingStage.test.tsx b/web/app/src/features/chat/ui/__tests__/RecordingStage.test.tsx similarity index 97% rename from web/app/src/components/chat/__tests__/RecordingStage.test.tsx rename to web/app/src/features/chat/ui/__tests__/RecordingStage.test.tsx index 4dbb3ee694f..91207920b09 100644 --- a/web/app/src/components/chat/__tests__/RecordingStage.test.tsx +++ b/web/app/src/features/chat/ui/__tests__/RecordingStage.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { RecordingStage } from '@/components/chat/RecordingStage'; -import type { TranscriptSegment } from '@/components/recording/RecordingContext'; +import { RecordingStage } from '@/features/chat/ui/RecordingStage'; +import type { TranscriptSegment } from '@/features/recording'; const reducedMotion = vi.hoisted(() => ({ value: false })); diff --git a/web/app/src/components/chat/__tests__/chatSessionSelection.test.tsx b/web/app/src/features/chat/ui/__tests__/chatSessionSelection.test.tsx similarity index 91% rename from web/app/src/components/chat/__tests__/chatSessionSelection.test.tsx rename to web/app/src/features/chat/ui/__tests__/chatSessionSelection.test.tsx index 7e9ad142dcc..64ad6f8c750 100644 --- a/web/app/src/components/chat/__tests__/chatSessionSelection.test.tsx +++ b/web/app/src/features/chat/ui/__tests__/chatSessionSelection.test.tsx @@ -2,7 +2,7 @@ import { act, render, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@/lib/api', () => ({ +vi.mock('@/features/chat/api', () => ({ getMessages: vi.fn().mockResolvedValue([]), sendMessageStream: vi.fn().mockResolvedValue(undefined), clearMessages: vi.fn().mockResolvedValue(undefined), @@ -11,10 +11,10 @@ vi.mock('@/lib/api', () => ({ })); vi.mock('@/lib/analytics/mixpanel', () => ({ MixpanelManager: {} })); -const { getMessages } = await import('@/lib/api'); +const { getMessages } = await import('@/features/chat/api'); const { ChatProvider, useChat: useChatContext } = - await import('@/components/chat/ChatContext'); -const { ChatPanel } = await import('@/components/chat/ChatPanel'); + await import('@/features/chat/ui/ChatContext'); +const { ChatPanel } = await import('@/features/chat/ui/ChatPanel'); function OpenWithSession({ sessionId }: { sessionId: string | null }) { const { openChat, selectChatSession } = useChatContext(); diff --git a/web/app/src/hooks/useChat.ts b/web/app/src/features/chat/useChat.ts similarity index 97% rename from web/app/src/hooks/useChat.ts rename to web/app/src/features/chat/useChat.ts index 49aedf70c37..2a9b81d0f4f 100644 --- a/web/app/src/hooks/useChat.ts +++ b/web/app/src/features/chat/useChat.ts @@ -1,14 +1,9 @@ 'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; -import { - getMessages, - sendMessageStream, - clearMessages as clearMessagesApi, - saveRealtimeMessage, -} from '@/lib/api'; +import { getMessages, sendMessageStream, clearMessages as clearMessagesApi, saveRealtimeMessage } from '@/features/chat/api'; import type { ClientMessage, MessageChunk, MessageFile } from '@/types/conversation'; -import type { ChatContextInfo } from '@/components/chat/ChatContext'; +import type { ChatContextInfo } from '@/features/chat/ui/ChatContext'; import { useRequestOwner } from '@/hooks/useRequestOwner'; interface UseChatOptions { diff --git a/web/app/src/hooks/useGeminiLive.ts b/web/app/src/features/chat/useGeminiLive.ts similarity index 97% rename from web/app/src/hooks/useGeminiLive.ts rename to web/app/src/features/chat/useGeminiLive.ts index a0f083db562..ff359ce624f 100644 --- a/web/app/src/hooks/useGeminiLive.ts +++ b/web/app/src/features/chat/useGeminiLive.ts @@ -1,9 +1,9 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { TranscriptSegment } from '@/components/recording/RecordingContext'; +import type { TranscriptSegment } from '@/features/recording'; import type { ClientMessage } from '@/types/conversation'; -import { createGeminiLiveSession, reportGeminiLiveUsage } from '@/lib/api'; +import { createGeminiLiveSession, reportGeminiLiveUsage } from '@/features/chat/api'; import { GeminiLiveClient } from '@/lib/geminiLive'; export type GeminiLiveState = 'idle' | 'connecting' | 'listening' | 'paused'; diff --git a/web/app/src/features/connectors/ARCHITECTURE.md b/web/app/src/features/connectors/ARCHITECTURE.md new file mode 100644 index 00000000000..2b407ccd546 --- /dev/null +++ b/web/app/src/features/connectors/ARCHITECTURE.md @@ -0,0 +1,7 @@ +# connectors + +Destination `/connectors`: installed apps and external services. + +- `api.ts` — app CRUD, enable/disable, integrations OAuth +- `model.ts` — tab param + integration summary +- `ui/` — explorer, form, detail, connected services diff --git a/web/app/src/lib/__tests__/connectors.test.ts b/web/app/src/features/connectors/__tests__/connectors.test.ts similarity index 93% rename from web/app/src/lib/__tests__/connectors.test.ts rename to web/app/src/features/connectors/__tests__/connectors.test.ts index 3c79b5d7c3e..efc6721dcbd 100644 --- a/web/app/src/lib/__tests__/connectors.test.ts +++ b/web/app/src/features/connectors/__tests__/connectors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { connectorTabFromParam, summarizeIntegrations } from '@/lib/connectors'; +import { connectorTabFromParam, summarizeIntegrations } from '../model'; import type { Integration } from '@/types/user'; function integration(overrides: Partial): Integration { diff --git a/web/app/src/features/connectors/api.ts b/web/app/src/features/connectors/api.ts new file mode 100644 index 00000000000..ea98f9e3c94 --- /dev/null +++ b/web/app/src/features/connectors/api.ts @@ -0,0 +1,527 @@ +import { getIdToken } from '@/lib/firebase'; +import { getWebDeviceIdHash } from '@/lib/clientDevice'; +import { + invalidateCache, + invalidationPatterns, + fetchWithCache, + cacheKeys, + CACHE_TTL, +} from '@/lib/cache'; +import { + API_BASE_URL, + fetchAuthorizedBlob, + fetchWithAuth, + getAudioAuthHeaders, +} from '@/shared/api/client'; +import type { + App, + AppCategory, + AppCapability, + AppsGroupedResponse, + AppsSearchResponse, + AppsSearchParams, + CreateAppRequest, + UpdateAppRequest, + ThumbnailUploadResponse, + GenerateDescriptionResponse, + NotificationScope, + PaymentPlan, +} from '@/types/apps'; +import type { Integration } from '@/types/user'; +export type { App } from '@/types/apps'; + +export async function getAppsGrouped( + params: { + capability?: string; + offset?: number; + limit?: number; + } = {}, +): Promise { + const { capability, offset = 0, limit = 20 } = params; + + const queryParams = new URLSearchParams({ + offset: offset.toString(), + limit: limit.toString(), + }); + + if (capability) { + queryParams.set('capability', capability); + } + + return fetchWithAuth(`/v2/apps?${queryParams}`); +} + +/** + * Search apps with filters + */ +export async function searchApps( + params: AppsSearchParams = {}, +): Promise { + const queryParams = new URLSearchParams(); + + if (params.q) queryParams.set('q', params.q); + if (params.category) queryParams.set('category', params.category); + if (params.capability) queryParams.set('capability', params.capability); + if (params.rating !== undefined) queryParams.set('rating', params.rating.toString()); + if (params.sort) queryParams.set('sort', params.sort); + if (params.my_apps) queryParams.set('my_apps', 'true'); + if (params.installed_apps) queryParams.set('installed_apps', 'true'); + queryParams.set('offset', (params.offset || 0).toString()); + queryParams.set('limit', (params.limit || 20).toString()); + + return fetchWithAuth(`/v2/apps/search?${queryParams}`); +} + +/** + * Get popular apps + */ +export async function getPopularApps(): Promise { + return fetchWithAuth('/v1/apps/popular'); +} + +/** + * Get a single app by ID + */ +export async function getApp(appId: string): Promise { + return fetchWithAuth(`/v1/apps/${appId}`); +} + +/** + * Get app categories + */ +export async function getAppCategories(): Promise { + return fetchWithAuth('/v1/app-categories'); +} + +/** + * Get app capabilities + */ +export async function getAppCapabilities(): Promise { + return fetchWithAuth('/v1/app-capabilities'); +} + +/** + * Enable (install) an app + */ +export async function enableApp(appId: string): Promise<{ status: string }> { + return fetchWithAuth<{ status: string }>(`/v1/apps/enable?app_id=${appId}`, { + method: 'POST', + }); +} + +/** + * Disable (uninstall) an app + */ +export async function disableApp(appId: string): Promise<{ status: string }> { + return fetchWithAuth<{ status: string }>(`/v1/apps/disable?app_id=${appId}`, { + method: 'POST', + }); +} + +/** + * Get installed apps + */ +export async function getInstalledApps(): Promise { + return searchApps({ installed_apps: true, limit: 100 }); +} + +/** + * Get chat-enabled apps (apps with 'chat' or 'persona' capability) + */ +export async function getChatApps(): Promise { + const response = await searchApps({ installed_apps: true, limit: 100 }); + return response.data.filter( + (app) => app.capabilities?.includes('chat') || app.capabilities?.includes('persona'), + ); +} + +// ============================================================================ +// App Creation/Editing API +// ============================================================================ + +/** + * Create a new app + */ +export async function createApp( + data: CreateAppRequest & { + deleted?: boolean; + price?: number; + thumbnails?: string[]; + uid?: string; + }, + imageFile?: File, +): Promise<{ app_id: string }> { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const url = `${API_BASE_URL}/v1/apps`; + + const formData = new FormData(); + formData.append('app_data', JSON.stringify(data)); + if (imageFile) { + formData.append('file', imageFile, imageFile.name); + } + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Create app error:', response.status, errorText); + throw new Error(`Failed to create app: ${response.status}`); + } + + return response.json(); +} + +/** + * Update an existing app + */ +export async function updateApp( + appId: string, + data: Partial, + imageFile?: File, +): Promise { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const url = `${API_BASE_URL}/v1/apps/${appId}`; + + // The API requires the id to be included in the app_data + const dataWithId = { ...data, id: appId }; + + const formData = new FormData(); + formData.append('app_data', JSON.stringify(dataWithId)); + if (imageFile) { + formData.append('file', imageFile, imageFile.name); + } + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Update app error:', response.status, errorText); + throw new Error(`Failed to update app: ${response.status}`); + } +} + +/** + * Re-enable an app that the backend auto-disabled after webhook failures. + * + * Sends `disabled: false` explicitly — the backend re-enable branch reads an + * unset-exclusive payload, so omitting the field is a no-op rather than a + * failure. The endpoint re-checks every configured URL and rejects the request + * with a specific reason, so that detail is surfaced instead of the status code. + */ +export async function reEnableApp(appId: string): Promise { + const token = await getIdToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const formData = new FormData(); + formData.append('app_data', JSON.stringify({ id: appId, disabled: false })); + + const response = await fetch(`${API_BASE_URL}/v1/apps/${appId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + if (!response.ok) { + const detail = await response + .json() + .then((body) => body?.detail) + .catch(() => null); + throw new Error(detail || `Failed to re-enable app: ${response.status}`); + } +} + +/** + * Delete an app + */ +export async function deleteApp(appId: string): Promise { + await fetchWithAuth(`/v1/apps/${appId}`, { + method: 'DELETE', + }); +} + +/** + * Upload app thumbnail + */ +export async function uploadAppThumbnail(file: File): Promise { + let token: string | null = null; + + try { + token = await getIdToken(); + } catch (tokenError) { + console.error('Failed to get auth token:', tokenError); + throw new Error('Failed to get authentication token'); + } + + if (!token) { + throw new Error('Not authenticated'); + } + + const url = `${API_BASE_URL}/v1/app/thumbnails`; + + const formData = new FormData(); + formData.append('file', file, file.name); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error body'); + console.error('Upload thumbnail error:', response.status, errorText); + throw new Error(`Failed to upload thumbnail: ${response.status}`); + } + + return response.json(); +} + +/** + * Generate app description using AI + */ +export async function generateAppDescription( + name: string, + currentDescription: string, +): Promise { + const response = await fetchWithAuth( + '/v1/app/generate-description', + { + method: 'POST', + body: JSON.stringify({ name, description: currentDescription }), + }, + ); + return response.description; +} + +/** + * Generate app description and emoji using AI + * Used for quick template creation (matches mobile app behavior) + */ +export async function generateAppDescriptionAndEmoji( + name: string, + prompt: string, +): Promise<{ description: string; emoji: string }> { + try { + const response = await fetchWithAuth<{ + description: string; + emoji: string; + }>('/v1/app/generate-description-emoji', { + method: 'POST', + body: JSON.stringify({ name, prompt }), + }); + return { + description: response.description || '', + emoji: response.emoji || '✨', + }; + } catch { + // Fallback: generate description only and use default emoji + const description = await generateAppDescription(name, prompt); + return { description, emoji: '✨' }; + } +} + +/** + * Get proactive notification scopes + * Note: This endpoint may not exist in all API versions, returns empty array on 404 + */ +export async function getNotificationScopes(): Promise { + try { + const token = await getIdToken(); + if (!token) return []; + + const response = await fetch( + `${API_BASE_URL}/v1/apps/proactive-notification-scopes`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-App-Platform': 'web', + }, + }, + ); + + if (!response.ok) return []; + return response.json(); + } catch { + return []; + } +} + +/** + * Get available payment plans + * Note: This endpoint may not exist in all API versions, returns empty array on 404 + */ +export async function getPaymentPlans(): Promise { + try { + const token = await getIdToken(); + if (!token) return []; + + const response = await fetch(`${API_BASE_URL}/v1/app/plans`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-App-Platform': 'web', + }, + }); + + if (!response.ok) return []; + return response.json(); + } catch { + return []; + } +} + +// Integration definitions with logo paths +const INTEGRATION_DEFINITIONS: Array<{ + id: string; + appKey: string; + name: string; + description: string; + logo: string; + coming_soon?: boolean; +}> = [ + { + id: 'google_calendar', + appKey: 'google_calendar', + name: 'Google Calendar', + description: 'Sync with your calendar', + logo: '/integrations/google-calendar.png', + }, + { + id: 'whoop', + appKey: 'whoop', + name: 'Whoop', + description: 'Health & fitness tracking', + logo: '/integrations/whoop.png', + }, + { + id: 'notion', + appKey: 'notion', + name: 'Notion', + description: 'Sync notes to Notion', + logo: '/integrations/notion-logo.png', + }, + { + id: 'github', + appKey: 'github', + name: 'GitHub', + description: 'Create issues and notes', + logo: '/integrations/github-logo.png', + }, + { + id: 'twitter', + appKey: 'twitter', + name: 'X (Twitter)', + description: 'Share to Twitter', + logo: '/integrations/x-logo.avif', + }, + { + id: 'gmail', + appKey: 'gmail', + name: 'Gmail', + description: 'Email integrations', + logo: '/integrations/gmail-logo.jpeg', + }, +]; + +/** + * Get individual integration connection status (like mobile app) + */ +async function getIntegrationStatus(appKey: string): Promise<{ connected: boolean }> { + try { + const response = await fetchWithAuth<{ + connected: boolean; + app_key: string; + }>(`/v1/integrations/${appKey}`); + return { connected: response.connected ?? false }; + } catch { + return { connected: false }; + } +} + +/** + * Get available integrations with connection status + * Fetches individual integration statuses like the mobile app does + */ +export async function getIntegrations(): Promise { + // Fetch all integration statuses in parallel + const statusPromises = INTEGRATION_DEFINITIONS.map(async (def) => { + const status = await getIntegrationStatus(def.appKey); + return { + id: def.id, + name: def.name, + description: def.description, + icon: def.logo, + connected: status.connected, + coming_soon: def.coming_soon, + }; + }); + + return Promise.all(statusPromises); +} + +/** + * Get OAuth URL for an integration + * Opens the OAuth flow for the user to authorize + */ +export async function getIntegrationOAuthUrl( + integrationId: string, +): Promise { + try { + const response = await fetchWithAuth<{ auth_url: string }>( + `/v1/integrations/${integrationId}/oauth-url`, + ); + return response.auth_url || null; + } catch { + return null; + } +} + +/** + * Disconnect an integration + */ +export async function disconnectIntegration(integrationId: string): Promise { + await fetchWithAuth(`/v1/integrations/${integrationId}`, { + method: 'DELETE', + }); +} diff --git a/web/app/src/features/connectors/index.ts b/web/app/src/features/connectors/index.ts new file mode 100644 index 00000000000..ab7cc5ed68f --- /dev/null +++ b/web/app/src/features/connectors/index.ts @@ -0,0 +1,7 @@ +export { AppsExplorer } from './ui/AppsExplorer'; +export { AppForm } from './ui/AppForm'; +export { AppDetail } from './ui/AppDetail'; +export { ConnectedServices } from './ui/ConnectedServices'; +export { connectorTabFromParam, summarizeIntegrations, type ConnectorTab } from './model'; +export type { App } from '@/types/apps'; +export * from './api'; diff --git a/web/app/src/lib/connectors.ts b/web/app/src/features/connectors/model.ts similarity index 100% rename from web/app/src/lib/connectors.ts rename to web/app/src/features/connectors/model.ts diff --git a/web/app/src/components/apps/AppCard.tsx b/web/app/src/features/connectors/ui/AppCard.tsx similarity index 98% rename from web/app/src/components/apps/AppCard.tsx rename to web/app/src/features/connectors/ui/AppCard.tsx index f65b83c0cb9..8fdf8644d86 100644 --- a/web/app/src/components/apps/AppCard.tsx +++ b/web/app/src/features/connectors/ui/AppCard.tsx @@ -5,7 +5,7 @@ import Image from '@tschk/moonshine-next/image'; import Link from '@tschk/moonshine-next/link'; import { Star, Download, Loader2, Check, Lock } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { enableApp, disableApp } from '@/lib/api'; +import { enableApp, disableApp } from '@/features/connectors/api'; import type { App } from '@/types/apps'; interface AppCardProps { diff --git a/web/app/src/components/apps/AppDetail.tsx b/web/app/src/features/connectors/ui/AppDetail.tsx similarity index 98% rename from web/app/src/components/apps/AppDetail.tsx rename to web/app/src/features/connectors/ui/AppDetail.tsx index dbbd487db24..8ab9f5b6fcf 100644 --- a/web/app/src/components/apps/AppDetail.tsx +++ b/web/app/src/features/connectors/ui/AppDetail.tsx @@ -19,11 +19,11 @@ import { Pencil, LayoutGrid, } from 'lucide-react'; -import { useAuth } from '@/components/auth/AuthProvider'; +import { useAuth } from '@/features/auth'; import { cn } from '@/lib/utils'; -import { getApp, enableApp, disableApp, reEnableApp } from '@/lib/api'; +import { getApp, enableApp, disableApp, reEnableApp } from '@/features/connectors/api'; import type { App } from '@/types/apps'; -import { AppDisabledNotice } from '@/components/apps/AppDisabledNotice'; +import { AppDisabledNotice } from '@/features/connectors/ui/AppDisabledNotice'; import { PageHeader } from '@/components/layout/PageHeader'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; diff --git a/web/app/src/components/apps/AppDisabledNotice.tsx b/web/app/src/features/connectors/ui/AppDisabledNotice.tsx similarity index 100% rename from web/app/src/components/apps/AppDisabledNotice.tsx rename to web/app/src/features/connectors/ui/AppDisabledNotice.tsx diff --git a/web/app/src/components/apps/AppForm.tsx b/web/app/src/features/connectors/ui/AppForm.tsx similarity index 99% rename from web/app/src/components/apps/AppForm.tsx rename to web/app/src/features/connectors/ui/AppForm.tsx index 41d07500a32..96ded6e5b8a 100644 --- a/web/app/src/components/apps/AppForm.tsx +++ b/web/app/src/features/connectors/ui/AppForm.tsx @@ -4,17 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from '@tschk/moonshine-next/navigation'; import Image from '@tschk/moonshine-next/image'; import { cn } from '@/lib/utils'; -import { - getAppCategories, - getAppCapabilities, - getNotificationScopes, - getPaymentPlans, - createApp, - updateApp, - uploadAppThumbnail, - generateAppDescription, - deleteApp, -} from '@/lib/api'; +import { getAppCategories, getAppCapabilities, getNotificationScopes, getPaymentPlans, createApp, updateApp, uploadAppThumbnail, generateAppDescription, deleteApp } from '@/features/connectors/api'; import type { App, AppCategory, diff --git a/web/app/src/components/apps/AppGridSection.tsx b/web/app/src/features/connectors/ui/AppGridSection.tsx similarity index 100% rename from web/app/src/components/apps/AppGridSection.tsx rename to web/app/src/features/connectors/ui/AppGridSection.tsx diff --git a/web/app/src/components/apps/AppsExplorer.tsx b/web/app/src/features/connectors/ui/AppsExplorer.tsx similarity index 99% rename from web/app/src/components/apps/AppsExplorer.tsx rename to web/app/src/features/connectors/ui/AppsExplorer.tsx index 8cd615f29d9..21ca0420b48 100644 --- a/web/app/src/components/apps/AppsExplorer.tsx +++ b/web/app/src/features/connectors/ui/AppsExplorer.tsx @@ -4,13 +4,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useRouter, useSearchParams } from '@tschk/moonshine-next/navigation'; import { Loader2, ChevronDown, Star, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { - getAppsGrouped, - searchApps, - getPopularApps, - getAppCategories, - getAppCapabilities, -} from '@/lib/api'; +import { getAppsGrouped, searchApps, getPopularApps, getAppCategories, getAppCapabilities } from '@/features/connectors/api'; import type { App, AppCategory, @@ -20,7 +14,7 @@ import type { SortOption, } from '@/types/apps'; import { AppCard } from './AppCard'; -import { connectorTabFromParam, type ConnectorTab } from '@/lib/connectors'; +import { connectorTabFromParam, type ConnectorTab } from '../model'; import { AppGridSection } from './AppGridSection'; import { ConnectedServices } from './ConnectedServices'; import { PageToolbar } from '@/components/layout/PageToolbar'; diff --git a/web/app/src/components/apps/ConnectedServices.tsx b/web/app/src/features/connectors/ui/ConnectedServices.tsx similarity index 98% rename from web/app/src/components/apps/ConnectedServices.tsx rename to web/app/src/features/connectors/ui/ConnectedServices.tsx index 72c4324420a..db49f743252 100644 --- a/web/app/src/components/apps/ConnectedServices.tsx +++ b/web/app/src/features/connectors/ui/ConnectedServices.tsx @@ -3,12 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Loader2, Puzzle } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { - disconnectIntegration, - getIntegrationOAuthUrl, - getIntegrations, -} from '@/lib/api'; -import { summarizeIntegrations } from '@/lib/connectors'; +import { disconnectIntegration, getIntegrationOAuthUrl, getIntegrations } from '@/features/connectors/api'; +import { summarizeIntegrations } from '../model'; import type { Integration } from '@/types/user'; function Toggle({ diff --git a/web/app/src/components/apps/__tests__/ConnectedServices.test.tsx b/web/app/src/features/connectors/ui/__tests__/ConnectedServices.test.tsx similarity index 97% rename from web/app/src/components/apps/__tests__/ConnectedServices.test.tsx rename to web/app/src/features/connectors/ui/__tests__/ConnectedServices.test.tsx index 880d2e77104..47561737e9e 100644 --- a/web/app/src/components/apps/__tests__/ConnectedServices.test.tsx +++ b/web/app/src/features/connectors/ui/__tests__/ConnectedServices.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConnectedServices } from '@/components/apps/ConnectedServices'; +import { ConnectedServices } from '@/features/connectors/ui/ConnectedServices'; import type { Integration } from '@/types/user'; const api = vi.hoisted(() => ({ @@ -9,7 +9,7 @@ const api = vi.hoisted(() => ({ getIntegrations: vi.fn(), })); -vi.mock('@/lib/api', () => api); +vi.mock('@/features/connectors/api', () => api); const disconnected: Integration = { id: 'gmail', diff --git a/web/app/src/features/conversations/ARCHITECTURE.md b/web/app/src/features/conversations/ARCHITECTURE.md new file mode 100644 index 00000000000..1da1bac4ed1 --- /dev/null +++ b/web/app/src/features/conversations/ARCHITECTURE.md @@ -0,0 +1,11 @@ +# conversations + +Destination `/conversations`: day-grouped gallery of conversations and recaps. + +- `api.ts` — HTTP (list, merge, folders, people, audio, daily summaries) +- `model.ts` — timeline grouping, panel sizing, selection from `?id=`/`?recap=` +- `ui/` — split view, detail panel (summary/actions tabs), gallery +- `recaps/` — recap detail; a recap leads its day, it is not a separate rail +- hooks — existing React list state (`useConversations`, `useRecaps`, …) + +Public entry: `index.ts`. diff --git a/web/app/src/lib/__tests__/conversationPanelSizing.test.ts b/web/app/src/features/conversations/__tests__/conversationPanelSizing.test.ts similarity index 94% rename from web/app/src/lib/__tests__/conversationPanelSizing.test.ts rename to web/app/src/features/conversations/__tests__/conversationPanelSizing.test.ts index 1f5d4248b16..ec43b1072c8 100644 --- a/web/app/src/lib/__tests__/conversationPanelSizing.test.ts +++ b/web/app/src/features/conversations/__tests__/conversationPanelSizing.test.ts @@ -3,7 +3,7 @@ import { MIN_CONVERSATION_DETAIL_WIDTH, MIN_CONVERSATION_GALLERY_WIDTH, resizeConversationDetailPanel, -} from '@/lib/conversationPanelSizing'; +} from '../model'; describe('conversation detail panel sizing', () => { it('can expand past the previous fixed 720px ceiling', () => { diff --git a/web/app/src/lib/__tests__/conversationTimeline.test.ts b/web/app/src/features/conversations/__tests__/conversationTimeline.test.ts similarity index 93% rename from web/app/src/lib/__tests__/conversationTimeline.test.ts rename to web/app/src/features/conversations/__tests__/conversationTimeline.test.ts index c85eb38ee95..72a74ca967c 100644 --- a/web/app/src/lib/__tests__/conversationTimeline.test.ts +++ b/web/app/src/features/conversations/__tests__/conversationTimeline.test.ts @@ -7,7 +7,8 @@ import { dayLabel, flattenTimelineItems, parseLocalDay, -} from '@/lib/conversationTimeline'; + selectionFromSearchParams, +} from '../model'; import type { Conversation } from '@/types/conversation'; import type { DailySummary } from '@/types/recap'; @@ -198,3 +199,13 @@ describe('buildTimelineDayGroups', () => { ]); }); }); + +describe('selectionFromSearchParams', () => { + it('prefers a recap id when both query params are present', () => { + expect(selectionFromSearchParams('c1', 'r1')).toEqual({ kind: 'recap', id: 'r1' }); + }); + + it('selects a conversation when only id is set', () => { + expect(selectionFromSearchParams('c1', null)).toEqual({ kind: 'conversation', id: 'c1' }); + }); +}); diff --git a/web/app/src/features/conversations/api.ts b/web/app/src/features/conversations/api.ts new file mode 100644 index 00000000000..e42379ed6d8 --- /dev/null +++ b/web/app/src/features/conversations/api.ts @@ -0,0 +1,615 @@ +import { getIdToken } from '@/lib/firebase'; +import { getWebDeviceIdHash } from '@/lib/clientDevice'; +import { + invalidateCache, + invalidationPatterns, + fetchWithCache, + cacheKeys, + CACHE_TTL, +} from '@/lib/cache'; +import { + API_BASE_URL, + fetchAuthorizedBlob, + fetchWithAuth, + getAudioAuthHeaders, +} from '@/shared/api/client'; +import type { + Conversation, + ConversationSearchResponse, + ConversationStatus, + AudioFileUrlInfo, +} from '@/types/conversation'; +import type { + MergeConversationsResponse, + CreateConversationResponse, +} from '@/lib/omiApi.generated'; +export type { MergeConversationsResponse, CreateConversationResponse }; +import type { DailySummary } from '@/types/recap'; +import type { Person } from '@/types/user'; +import type { Folder, CreateFolderRequest, UpdateFolderRequest } from '@/types/folder'; + +export interface GetConversationsParams { + limit?: number; + offset?: number; + statuses?: ConversationStatus[]; + includeDiscarded?: boolean; + startDate?: Date; + endDate?: Date; + folderId?: string; +} + +export async function getConversations( + params: GetConversationsParams = {}, +): Promise { + const { + limit = 50, + offset = 0, + statuses = ['processing', 'completed'], + includeDiscarded = false, + startDate, + endDate, + folderId, + } = params; + + const queryParams = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + include_discarded: includeDiscarded.toString(), + statuses: statuses.join(','), + }); + + if (startDate) { + queryParams.set('start_date', startDate.toISOString()); + } + + if (endDate) { + queryParams.set('end_date', endDate.toISOString()); + } + + if (folderId) { + queryParams.set('folder_id', folderId); + } + + return fetchWithAuth(`/v1/conversations?${queryParams}`); +} + +/** + * Get a single conversation by ID + * Uses centralized cache with request deduplication + */ +export async function getConversation(id: string): Promise { + return fetchWithCache( + cacheKeys.conversation(id), + () => fetchWithAuth(`/v1/conversations/${id}`), + { ttl: CACHE_TTL.SHORT }, + ); +} + +/** + * Search conversations + */ +export interface SearchConversationsParams { + query: string; + page?: number; + perPage?: number; + includeDiscarded?: boolean; +} + +export async function searchConversations( + params: SearchConversationsParams, +): Promise { + const { query, page = 1, perPage = 10, includeDiscarded = false } = params; + + return fetchWithAuth('/v1/conversations/search', { + method: 'POST', + body: JSON.stringify({ + query, + page, + per_page: perPage, + include_discarded: includeDiscarded, + }), + }); +} + +/** + * Toggle conversation starred status + */ +export async function toggleStarred(id: string, starred: boolean): Promise { + await fetchWithAuth(`/v1/conversations/${id}/starred?starred=${starred}`, { + method: 'PATCH', + }); + invalidateCache(invalidationPatterns.conversations); +} + +/** + * Delete a conversation + */ +export async function deleteConversation(id: string): Promise { + await fetchWithAuth(`/v1/conversations/${id}`, { + method: 'DELETE', + }); + invalidateCache(invalidationPatterns.conversations); +} + +/** + * Merge multiple conversations into one + * @param conversationIds - Array of conversation IDs to merge + * @param reprocess - Whether to reprocess the merged conversation (default: true) + * @returns Response with status and merged conversation IDs + */ +export async function mergeConversations( + conversationIds: string[], + reprocess: boolean = true, +): Promise { + return fetchWithAuth('/v1/conversations/merge', { + method: 'POST', + body: JSON.stringify({ + conversation_ids: conversationIds, + reprocess, + }), + }); +} + +/** + * Finalize an in-progress conversation from a recording session. + * This processes the transcript, generates title/summary/action items, + * and creates audio file records from stored chunks. + * + * @returns The processed conversation, or null if no in-progress conversation exists + */ +export async function processInProgressConversation(): Promise { + try { + const result = await fetchWithAuth('/v1/conversations', { + method: 'POST', + body: JSON.stringify({}), + }); + invalidateCache(invalidationPatterns.conversations); + return result; + } catch (error) { + // 404 means no in-progress conversation exists + if (error instanceof Error && error.message.includes('404')) { + return null; + } + throw error; + } +} + +/** + * Finalize exactly one conversation by ID (desktop-style). + * Prefer this over processInProgressConversation when a socket owns a + * conversation_id — the Redis in_progress pointer is shared across device + + * web and must not steal a pendant session (#5388). + */ +export async function finalizeConversationById( + conversationId: string, +): Promise { + try { + const result = await fetchWithAuth( + `/v1/conversations/${encodeURIComponent(conversationId)}/finalize`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ); + invalidateCache(invalidationPatterns.conversations); + return result; + } catch (error) { + if (error instanceof Error && error.message.includes('404')) { + return null; + } + throw error; + } +} + +// ============================================================================ +// Action Items (Tasks) API +// ============================================================================ + +/** + * Get all action items + */ +export interface GetDailySummariesParams { + limit?: number; + offset?: number; +} + +/** + * Get list of daily summaries with pagination + */ +export async function getDailySummaries( + params: GetDailySummariesParams = {}, +): Promise { + const { limit = 30, offset = 0 } = params; + const queryParams = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + return fetchWithAuth(`/v1/users/daily-summaries?${queryParams}`); +} + +/** + * Get a single daily summary by ID + */ +export async function getDailySummary(id: string): Promise { + return fetchWithAuth(`/v1/users/daily-summaries/${id}`); +} + +/** + * Delete a daily summary + */ +export async function deleteDailySummary(id: string): Promise { + await fetchWithAuth(`/v1/users/daily-summaries/${id}`, { + method: 'DELETE', + }); +} + +/** + * Generate a test daily summary for a specific date + */ +export async function generateTestDailySummary(date: string): Promise { + return fetchWithAuth('/v1/users/daily-summary-settings/test', { + method: 'POST', + body: JSON.stringify({ date }), + }); +} + +/** + * Get all people for speaker identification + */ +export async function getPeople(): Promise { + return fetchWithAuth('/v1/users/people'); +} + +/** + * Create a new person + */ +export async function createPerson(name: string): Promise { + return fetchWithAuth('/v1/users/people', { + method: 'POST', + body: JSON.stringify({ name }), + }); +} + +/** + * Update person name + */ +export async function updatePersonName(personId: string, name: string): Promise { + await fetchWithAuth(`/v1/users/people/${personId}/name`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }); +} + +/** + * Delete a person + */ +export async function deletePerson(personId: string): Promise { + await fetchWithAuth(`/v1/users/people/${personId}`, { + method: 'DELETE', + }); +} + +/** + * Bulk assign speaker to transcript segments + * @param conversationId - The conversation ID + * @param segmentIds - Array of segment IDs to assign + * @param isUser - If true, marks segments as user's speech + * @param personId - Person ID to assign (null to unassign) + */ +export async function assignBulkTranscriptSegments( + conversationId: string, + segmentIds: string[], + options: { isUser?: boolean; personId?: string | null }, +): Promise { + const { isUser, personId } = options; + + let assignType: 'is_user' | 'person_id'; + let value: string | null; + + if (isUser) { + assignType = 'is_user'; + value = 'true'; + } else { + assignType = 'person_id'; + value = personId ?? null; + } + + await fetchWithAuth(`/v1/conversations/${conversationId}/segments/assign-bulk`, { + method: 'PATCH', + body: JSON.stringify({ + segment_ids: segmentIds, + assign_type: assignType, + value, + }), + }); +} + +/** + * Error thrown when editing a transcript segment requires a paid plan + * (backend returns HTTP 402 for `/segments/text` on gated accounts). + */ +export class SegmentEditPlanRequiredError extends Error { + constructor(message = 'Editing the transcript requires the Unlimited plan.') { + super(message); + this.name = 'SegmentEditPlanRequiredError'; + } +} + +/** + * Update the text of a single transcript segment. + * + * Mirrors the backend `PATCH /v1/conversations/{id}/segments/text` + * (`UpdateSegmentTextRequest`), which identifies the segment by its `id` and + * rewrites just that segment's text. The response is a bare `{status}`, so + * callers must optimistically patch their local `transcript_segments`. + * + * @param conversationId - The conversation ID + * @param segmentId - The `id` of the segment to edit (non-empty) + * @param text - New segment text (1–10000 chars, enforced by the backend) + * @throws SegmentEditPlanRequiredError when the account is plan-gated (402) + */ +export async function updateSegmentText( + conversationId: string, + segmentId: string, + text: string, +): Promise { + try { + await fetchWithAuth(`/v1/conversations/${conversationId}/segments/text`, { + method: 'PATCH', + body: JSON.stringify({ segment_id: segmentId, text }), + }); + } catch (error) { + // fetchWithAuth surfaces the status in the thrown message (`API error: 402 ...`). + if (error instanceof Error && error.message.includes('402')) { + throw new SegmentEditPlanRequiredError(); + } + throw error; + } +} + +/** + * Reprocess a conversation (rebuild summary/search after transcript edits) + */ +export async function reprocessConversation( + conversationId: string, + appId?: string, +): Promise { + const queryParams = new URLSearchParams(); + if (appId) { + queryParams.set('app_id', appId); + } + + const endpoint = `/v1/conversations/${conversationId}/reprocess${queryParams.toString() ? `?${queryParams}` : ''}`; + return fetchWithAuth(endpoint, { + method: 'POST', + }); +} + +/** + * Update a conversation's title + * @param conversationId - The ID of the conversation + * @param title - The new title + */ +export async function updateConversationTitle( + conversationId: string, + title: string, +): Promise { + await fetchWithAuth( + `/v1/conversations/${conversationId}/title?title=${encodeURIComponent(title)}`, + { + method: 'PATCH', + }, + ); +} + +/** + * Test a custom prompt against a conversation + * @param conversationId - The ID of the conversation + * @param prompt - The custom prompt to test + * @returns The generated summary + */ +export async function testConversationPrompt( + conversationId: string, + prompt: string, +): Promise { + const response = await fetchWithAuth<{ summary: string }>( + `/v1/conversations/${conversationId}/test-prompt`, + { + method: 'POST', + body: JSON.stringify({ prompt }), + }, + ); + return response.summary; +} + +// ============================================================================= +// Audio API +// ============================================================================= + +/** + * Get the streaming URL for an audio file + * @param conversationId - The conversation ID + * @param audioFileId - The audio file ID + * @param format - Audio format (default: wav) + */ +export function getAudioStreamUrl( + conversationId: string, + audioFileId: string, + format: string = 'wav', +): string { + return `${API_BASE_URL}/v1/sync/audio/${conversationId}/${audioFileId}?format=${format}`; +} + +/** + * Get signed URLs for conversation audio files + * Returns direct GCS URLs for cached files or status for pending files + * @param conversationId - The conversation ID + */ +export async function getConversationAudioUrls( + conversationId: string, + signal?: AbortSignal, +): Promise { + try { + const response = await fetchWithAuth<{ audio_files: AudioFileUrlInfo[] }>( + `/v1/sync/audio/${conversationId}/urls`, + { signal }, + ); + return response.audio_files || []; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return []; // Silently return empty for aborted requests + } + console.error('Error fetching audio URLs:', error); + return []; + } +} + +/** + * Get signed URLs plus the server's poll hint (poll_after_ms) while + * playback artifacts are still being built. + * @param conversationId - The conversation ID + */ +export async function getConversationAudioUrlsWithPoll( + conversationId: string, + signal?: AbortSignal, +): Promise<{ files: AudioFileUrlInfo[]; pollAfterMs: number | null }> { + try { + const response = await fetchWithAuth<{ + audio_files: AudioFileUrlInfo[]; + poll_after_ms?: number | null; + }>(`/v1/sync/audio/${conversationId}/urls`, { signal }); + return { + files: response.audio_files || [], + pollAfterMs: response.poll_after_ms ?? null, + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { files: [], pollAfterMs: null }; + } + console.error('Error fetching audio URLs:', error); + return { files: [], pollAfterMs: null }; + } +} + +/** + * Pre-cache audio files for a conversation + * Triggers background caching of audio files for faster playback + * @param conversationId - The conversation ID + */ +export async function precacheConversationAudio( + conversationId: string, + signal?: AbortSignal, +): Promise { + try { + await fetchWithAuth(`/v1/sync/audio/${conversationId}/precache`, { + method: 'POST', + signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; // Silently return for aborted requests + } + console.error('Error pre-caching audio:', error); + } +} + +export { getAudioAuthHeaders } from '@/shared/api/client'; + +/** + * Fetch audio file and return a Blob URL for playback + * This works around the HTML