diff --git a/.github/checks-manifest.yaml b/.github/checks-manifest.yaml index c4f77b1d2e9..80771f5e0db 100644 --- a/.github/checks-manifest.yaml +++ b/.github/checks-manifest.yaml @@ -361,7 +361,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/backend/tests/unit/test_byok_security.py b/backend/tests/unit/test_byok_security.py index a72f4eb1236..42f05e43f5f 100644 --- a/backend/tests/unit/test_byok_security.py +++ b/backend/tests/unit/test_byok_security.py @@ -843,7 +843,6 @@ def test_requires_validated_context(self, monkeypatch): monkeypatch.setattr(subscription, 'get_byok_keys', lambda: {'openai': 'sk'}) assert subscription.request_has_llm_byok_key() is False - def test_quota_snapshot_accepts_required_llm_provider(self, monkeypatch): from models.users import PlanType from utils import subscription diff --git a/backend/utils/subscription.py b/backend/utils/subscription.py index 6294a20cb9e..8d71d4baa53 100644 --- a/backend/utils/subscription.py +++ b/backend/utils/subscription.py @@ -221,10 +221,7 @@ def request_has_llm_byok_key() -> bool: try: fingerprints = get_cached_byok_state(uid).get('fingerprints', {}) except Exception: - return any( - get_byok_key(provider) - for provider in ('openrouter', 'openai', 'anthropic', 'gemini') - ) + return any(get_byok_key(provider) for provider in ('openrouter', 'openai', 'anthropic', 'gemini')) return any( provider in fingerprints and bool(get_byok_key(provider)) for provider in ('openrouter', 'openai', 'anthropic', 'gemini') diff --git a/docs/agents/web-app-destinations.md b/docs/agents/web-app-destinations.md index 9f3b2e3463c..615554a8e2c 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. | @@ -30,7 +30,7 @@ because they share this component, not because someone kept them in sync. `PageHeader` is the different thing it looks like: a back button beside a title, for detail and sub pages. -shadcn/ui is set up (`web/app/components.json`, primitives in `web/app/src/components/ui/` +shadcn/ui is set up (`web/app/components.json`, primitives in `web/app/src/shared/ui/` lowercase). Its generator emits literal `oklch(...)` strings, which are not valid classes in this project, so every primitive is restyled onto the Omi tokens on the way in. Do that for any primitive you add. diff --git a/docs/agents/web-app-signals.md b/docs/agents/web-app-signals.md index 6368dddb83e..b41b5f86418 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`. Same pattern: conversations, recaps, memories, chat, search, notifications, knowledge graph, Gemini Live, goal advice | | 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..9f0fae87528 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 and UI primitives). 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..69108dedc5b --- /dev/null +++ b/web/app/ARCHITECTURE.md @@ -0,0 +1,39 @@ +# 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/shared/ui` — Toast, dialogs, input, maps, startup modals (no feature imports) +- `src/lib` — firebase, cache, utils, generated OpenAPI types (not yet moved) +- `src/components/layout` — shell chrome (may import features) +- Tailwind `content` is `src/**` so feature class names reach `styles.css` +- `src/hooks/useAsyncResource.ts`, `useLocalStorage`, `useRequestOwner`, + `useScrollEdges` — generic hooks, not a domain + +Writable moonshine stores hold lists and session state written from the UI +(goals, tasks, people, conversations, recaps, memories, chat, search, +notifications, knowledge graph, Gemini Live, goal advice). Read-only fetches +use `useAsyncResource` (`useHomeTasks`, `useGoalDetail` history). + +## 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/bun.lock b/web/app/bun.lock index 0bc1aee3ba9..e61891b5f60 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", @@ -233,14 +221,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=="], @@ -307,57 +287,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=="], @@ -367,18 +319,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=="], @@ -565,10 +507,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=="], @@ -673,8 +611,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=="], @@ -1291,10 +1227,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=="], @@ -1527,54 +1459,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=="], @@ -1621,24 +1505,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=="], @@ -1646,9 +1512,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/components.json b/web/app/components.json index e92fe4d0dd7..e138d9b678c 100644 --- a/web/app/components.json +++ b/web/app/components.json @@ -13,7 +13,7 @@ "aliases": { "components": "@/components", "utils": "@/lib/utils", - "ui": "@/components/ui", + "ui": "@/shared/ui", "lib": "@/lib", "hooks": "@/hooks" }, diff --git a/web/app/package.json b/web/app/package.json index 83ca484bc22..ebafbee77a1 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -10,19 +10,13 @@ "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-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 +30,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 +44,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 +59,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/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/scripts/moonshine-smoke.test.ts b/web/app/scripts/moonshine-smoke.test.ts index 74dcf7bf5f7..da6caab561c 100644 --- a/web/app/scripts/moonshine-smoke.test.ts +++ b/web/app/scripts/moonshine-smoke.test.ts @@ -66,4 +66,11 @@ describe('Moonshine web routes', () => { }).NEXT_PUBLIC_WS_BASE_URL, ).toBe('wss://recording.example.com'); }); + + test('Tailwind content globs include feature modules', async () => { + const { readFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const source = await readFile(join(import.meta.dir, '../tailwind.config.ts'), 'utf8'); + expect(source.includes('src/**') || source.includes('src/features')).toBe(true); + }); }); 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..c0bb180df6f 100644 --- a/web/app/src/app/(authenticated)/layout.tsx +++ b/web/app/src/app/(authenticated)/layout.tsx @@ -1,6 +1,6 @@ -import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { ProtectedRoute } from '@/features/auth'; import { MainLayout } from '@/components/layout/MainLayout'; -import { StartupModals } from '@/components/ui/StartupModals'; +import { StartupModals } from '@/shared/ui/StartupModals'; export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) { return ( 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..33862fac13a 100644 --- a/web/app/src/app/layout.tsx +++ b/web/app/src/app/layout.tsx @@ -1,6 +1,6 @@ -import { AuthProvider } from '@/components/auth/AuthProvider'; -import { RecordingProvider, RecordingController } from '@/components/recording'; -import { ToastProvider } from '@/components/ui/Toast'; +import { AuthProvider } from '@/features/auth'; +import { RecordingProvider, RecordingController } from '@/features/recording'; +import { ToastProvider } from '@/shared/ui/Toast'; import { PublicBuildCanary } from '@/components/public-build-canary'; export default function RootLayout({ children }: { children: React.ReactNode }) { 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/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/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/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/layout/PageToolbar.tsx b/web/app/src/components/layout/PageToolbar.tsx index 4adc58e7c81..d5917497b59 100644 --- a/web/app/src/components/layout/PageToolbar.tsx +++ b/web/app/src/components/layout/PageToolbar.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { Search, X } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { Input } from '@/components/ui/input'; +import { Input } from '@/shared/ui/input'; export interface PageToolbarSearch { value: string; diff --git a/web/app/src/components/layout/Sidebar.tsx b/web/app/src/components/layout/Sidebar.tsx index bdb96ea8637..3345757353d 100644 --- a/web/app/src/components/layout/Sidebar.tsx +++ b/web/app/src/components/layout/Sidebar.tsx @@ -48,12 +48,12 @@ 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'; +import { ConfettiBurst } from '@/shared/ui/ConfettiBurst'; /** How long the banner takes to swell and pop, and the burst to clear it. */ const BANNER_BURST_MS = 420; 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/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/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/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/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/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/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/components/auth/__tests__/AuthProvider.test.tsx b/web/app/src/features/auth/ui/__tests__/AuthProvider.test.tsx similarity index 100% rename from web/app/src/components/auth/__tests__/AuthProvider.test.tsx rename to web/app/src/features/auth/ui/__tests__/AuthProvider.test.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..1c8709757dc --- /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` (home pill) and `ChatPanel` (overlay) share `useChatAttachments`; they are not one visual composer +- `useChat.ts` / `useGeminiLive.ts` — signal stores; live session ownership is still the client-ref guard 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..a3eb8d46226 --- /dev/null +++ b/web/app/src/features/chat/api.ts @@ -0,0 +1,397 @@ +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; + start_date?: string; + end_date?: 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 + ? { + type: options.context.type === 'general' ? 'recap' : options.context.type, + id: options.context.id, + title: options.context.title, + start_date: options.context.start_date, + end_date: options.context.end_date, + } + : 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 72% rename from web/app/src/components/chat/ChatComposer.tsx rename to web/app/src/features/chat/ui/ChatComposer.tsx index 728211126d5..83446a23f2b 100644 --- a/web/app/src/components/chat/ChatComposer.tsx +++ b/web/app/src/features/chat/ui/ChatComposer.tsx @@ -4,11 +4,11 @@ import { useRef, useState, useEffect, useImperativeHandle } from 'react'; 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 { OmiPulseMark } from '@/shared/ui/OmiPulseMark'; import type { MessageFile } from '@/types/conversation'; import { cn } from '@/lib/utils'; import { shouldSubmitComposerKey } from '@/lib/chatComposerKey'; +import { useChatAttachments } from './useChatAttachments'; /** * The ask bar: one pill that carries the text and every control that acts on @@ -19,14 +19,6 @@ import { shouldSubmitComposerKey } from '@/lib/chatComposerKey'; * draft and upload state and reports only completed sends upward. */ -interface FilePreviewItem { - file: File; - preview?: string; - uploading?: boolean; - uploadedId?: string; - uploadedFile?: MessageFile; -} - export interface ChatComposerHandle { focus: () => void; } @@ -69,13 +61,18 @@ export function ChatComposer({ ref, }: ChatComposerProps) { const [input, setInput] = useState(''); - const [selectedFiles, setSelectedFiles] = useState([]); - const [pendingUploadCount, setPendingUploadCount] = useState(0); + const { + selectedFiles, + isUploading, + fileInputRef, + pendingUploadCountRef, + handleFileSelect, + handleRemoveFile, + takeReadyFiles, + clear: clearAttachments, + } = useChatAttachments(appId); const inputRef = useRef(null); - const fileInputRef = useRef(null); - const pendingUploadCountRef = useRef(0); - const isUploading = pendingUploadCount > 0; useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus() }), []); @@ -87,75 +84,6 @@ export function ChatComposer({ } }, [input]); - // Handle file selection - const handleFileSelect = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; - - // Limit to MAX_FILES - const availableSlots = MAX_FILES - selectedFiles.length; - const filesToAdd = files.slice(0, availableSlots); - - // Create preview items - const newItems: FilePreviewItem[] = filesToAdd.map((file) => { - let preview: string | undefined; - if (file.type.startsWith('image/')) { - preview = URL.createObjectURL(file); - } - return { file, preview, uploading: true }; - }); - - setSelectedFiles((prev) => [...prev, ...newItems]); - - // Upload files - pendingUploadCountRef.current += 1; - setPendingUploadCount((current) => current + 1); - try { - const uploadedFiles = await uploadChatFiles(filesToAdd, appId); - - // Update items with uploaded IDs - setSelectedFiles((prev) => - prev.map((item) => { - const fileIndex = filesToAdd.indexOf(item.file); - const uploadedFile = fileIndex >= 0 ? uploadedFiles[fileIndex] : undefined; - if (uploadedFile) { - return { - ...item, - uploading: false, - uploadedId: uploadedFile.id, - uploadedFile, - }; - } - return item; - }), - ); - } catch (err) { - console.error('Failed to upload files:', err); - // Remove failed uploads - setSelectedFiles((prev) => prev.filter((item) => !filesToAdd.includes(item.file))); - } finally { - pendingUploadCountRef.current = Math.max(0, pendingUploadCountRef.current - 1); - setPendingUploadCount((current) => Math.max(0, current - 1)); - } - - // Reset input - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - // Remove file from selection - const handleRemoveFile = (index: number) => { - setSelectedFiles((prev) => { - const item = prev[index]; - // Revoke object URL if it was an image - if (item.preview) { - URL.revokeObjectURL(item.preview); - } - return prev.filter((_, i) => i !== index); - }); - }; - // Handle voice transcript - append to input and focus const handleVoiceTranscript = (transcript: string) => { setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)); @@ -173,12 +101,10 @@ export function ChatComposer({ return; // Get file IDs from uploaded files - const uploadedFiles = selectedFiles.flatMap((item) => - item.uploadedFile ? [item.uploadedFile] : [], - ); + const uploadedFiles = takeReadyFiles(); setInput(''); - setSelectedFiles([]); + clearAttachments(); if (inputRef.current) { inputRef.current.style.height = 'auto'; } 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 cdeded840a1..704c371aa6d 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 87% rename from web/app/src/components/chat/ChatPanel.tsx rename to web/app/src/features/chat/ui/ChatPanel.tsx index 9bf874fb533..3298ba3b62c 100644 --- a/web/app/src/components/chat/ChatPanel.tsx +++ b/web/app/src/features/chat/ui/ChatPanel.tsx @@ -4,22 +4,16 @@ import { useRef, useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Send, Sparkles, Trash2, Brain, Paperclip, ArrowLeft } from 'lucide-react'; import { useChat as useChatContext } from './ChatContext'; -import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { ConfirmDialog } from '@/shared/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 { getChatApps } from '@/features/connectors'; +import type { App } from '@/features/connectors'; import { cn } from '@/lib/utils'; import { MixpanelManager } from '@/lib/analytics/mixpanel'; import { shouldSubmitComposerKey } from '@/lib/chatComposerKey'; import { ChatMarkdown } from './ChatMarkdown'; - -interface FilePreviewItem { - file: File; - preview?: string; - uploading?: boolean; - uploadedId?: string; -} +import { useChatAttachments } from './useChatAttachments'; // Quick prompts based on context function getQuickPrompts(contextType: string | undefined): string[] { @@ -72,12 +66,20 @@ export function ChatPanel() { const [input, setInput] = useState(''); const [showClearDialog, setShowClearDialog] = useState(false); const [isClearing, setIsClearing] = useState(false); - const [selectedFiles, setSelectedFiles] = useState([]); - const [isUploading, setIsUploading] = useState(false); const [selectedApp, setSelectedApp] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); - const fileInputRef = useRef(null); + const { + selectedFiles, + isUploading, + fileInputRef, + handleFileSelect, + handleRemoveFile, + takeReadyFiles, + clear: clearAttachments, + hasReadyUpload, + atFileLimit, + } = useChatAttachments(selectedAppId ?? undefined); // Fetch app info when selectedAppId changes useEffect(() => { @@ -196,70 +198,6 @@ export function ChatPanel() { }); }; - // Handle file selection - const handleFileSelect = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; - - // Limit to MAX_FILES - const availableSlots = MAX_FILES - selectedFiles.length; - const filesToAdd = files.slice(0, availableSlots); - - // Create preview items - const newItems: FilePreviewItem[] = await Promise.all( - filesToAdd.map(async (file) => { - let preview: string | undefined; - if (file.type.startsWith('image/')) { - preview = URL.createObjectURL(file); - } - return { file, preview, uploading: true }; - }), - ); - - setSelectedFiles((prev) => [...prev, ...newItems]); - - // Upload files - setIsUploading(true); - try { - const uploadedFiles = await uploadChatFiles(filesToAdd); - - // Update items with uploaded IDs - setSelectedFiles((prev) => - prev.map((item) => { - const fileIndex = filesToAdd.indexOf(item.file); - const uploadedFile = fileIndex >= 0 ? uploadedFiles[fileIndex] : undefined; - if (uploadedFile) { - return { ...item, uploading: false, uploadedId: uploadedFile.id }; - } - return item; - }), - ); - } catch (err) { - console.error('Failed to upload files:', err); - // Remove failed uploads - setSelectedFiles((prev) => prev.filter((item) => !filesToAdd.includes(item.file))); - } finally { - setIsUploading(false); - } - - // Reset input - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - // Remove file from selection - const handleRemoveFile = (index: number) => { - setSelectedFiles((prev) => { - const item = prev[index]; - // Revoke object URL if it was an image - if (item.preview) { - URL.revokeObjectURL(item.preview); - } - return prev.filter((_, i) => i !== index); - }); - }; - // Handle voice transcript - append to input and focus const handleVoiceTranscript = (transcript: string) => { setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)); @@ -267,17 +205,10 @@ export function ChatPanel() { }; const handleSend = async (text: string = input) => { - if ( - (!text.trim() && !selectedFiles.some((item) => item.uploadedId)) || - isLoading || - isStreaming - ) - return; + const uploadedFiles = takeReadyFiles(); + if ((!text.trim() && uploadedFiles.length === 0) || isLoading || isStreaming) return; - // Get file IDs from uploaded files - const fileIds = selectedFiles - .filter((item) => item.uploadedId) - .map((item) => item.uploadedId as string); + const fileIds = uploadedFiles.map((file) => file.id); MixpanelManager.track('Chat Message Sent', { message_length: text.length, @@ -286,8 +217,8 @@ export function ChatPanel() { }); setInput(''); - setSelectedFiles([]); - await sendMessage(text, fileIds, currentContext); + clearAttachments(); + await sendMessage(text, fileIds, currentContext, uploadedFiles); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -308,10 +239,7 @@ export function ChatPanel() { }; const canSend = - (input.trim() || selectedFiles.some((f) => f.uploadedId)) && - !isLoading && - !isStreaming && - !isUploading; + (input.trim() || hasReadyUpload) && !isLoading && !isStreaming && !isUploading; return ( @@ -599,7 +527,7 @@ export function ChatPanel() { 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 96% rename from web/app/src/components/chat/FilePreview.tsx rename to web/app/src/features/chat/ui/FilePreview.tsx index 04f5e7b225d..201416463a8 100644 --- a/web/app/src/components/chat/FilePreview.tsx +++ b/web/app/src/features/chat/ui/FilePreview.tsx @@ -2,13 +2,15 @@ import { X, FileText, Image as ImageIcon, Loader2 } from 'lucide-react'; import Image from '@tschk/moonshine-next/image'; +import type { MessageFile } from '@/types/conversation'; import { cn } from '@/lib/utils'; -interface FilePreviewItem { +export interface FilePreviewItem { file: File; preview?: string; uploading?: boolean; uploadedId?: string; + uploadedFile?: MessageFile; } interface FilePreviewProps { diff --git a/web/app/src/components/chat/RecordingStage.tsx b/web/app/src/features/chat/ui/RecordingStage.tsx similarity index 97% rename from web/app/src/components/chat/RecordingStage.tsx rename to web/app/src/features/chat/ui/RecordingStage.tsx index 81c04fd7aea..daf48a8ce73 100644 --- a/web/app/src/components/chat/RecordingStage.tsx +++ b/web/app/src/features/chat/ui/RecordingStage.tsx @@ -3,8 +3,8 @@ 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 { OmiPulseMark } from '@/components/ui/OmiPulseMark'; +import type { TranscriptSegment } from '@/features/recording'; +import { OmiPulseMark } from '@/shared/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 98% rename from web/app/src/components/chat/__tests__/ChatComposer.test.tsx rename to web/app/src/features/chat/ui/__tests__/ChatComposer.test.tsx index eecaeb8cd58..27da32989ec 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__/ChatTranscript.test.tsx b/web/app/src/features/chat/ui/__tests__/ChatTranscript.test.tsx similarity index 96% rename from web/app/src/components/chat/__tests__/ChatTranscript.test.tsx rename to web/app/src/features/chat/ui/__tests__/ChatTranscript.test.tsx index cae6a1d5a85..7571279e0b4 100644 --- a/web/app/src/components/chat/__tests__/ChatTranscript.test.tsx +++ b/web/app/src/features/chat/ui/__tests__/ChatTranscript.test.tsx @@ -1,12 +1,12 @@ import { render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatTranscript } from '@/components/chat/ChatTranscript'; +import { ChatTranscript } from '@/features/chat/ui/ChatTranscript'; import type { ClientMessage } from '@/types/conversation'; vi.mock('@tschk/moonshine-next/image', () => ({ default: ({ alt }: React.ComponentProps<'img'>) => {alt}, })); -vi.mock('@/components/chat/ChatMarkdown', () => ({ +vi.mock('@/features/chat/ui/ChatMarkdown', () => ({ ChatMarkdown: ({ children }: { children: string }) =>
{children}
, })); 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/features/chat/ui/useChatAttachments.ts b/web/app/src/features/chat/ui/useChatAttachments.ts new file mode 100644 index 00000000000..ba1a6baa64a --- /dev/null +++ b/web/app/src/features/chat/ui/useChatAttachments.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { uploadChatFiles } from '@/features/chat/api'; +import { MAX_FILES, type FilePreviewItem } from './FilePreview'; +import type { MessageFile } from '@/types/conversation'; + +/** + * Shared draft-attachment state for Home's pill composer and the overlay panel. + * Upload lives here so the two UIs do not fork the MAX_FILES / preview / rollback path. + */ +export function useChatAttachments(appId?: string) { + const [selectedFiles, setSelectedFiles] = useState([]); + const [pendingUploadCount, setPendingUploadCount] = useState(0); + const fileInputRef = useRef(null); + const pendingUploadCountRef = useRef(0); + const isUploading = pendingUploadCount > 0; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + const availableSlots = MAX_FILES - selectedFiles.length; + const filesToAdd = files.slice(0, availableSlots); + + const newItems: FilePreviewItem[] = filesToAdd.map((file) => { + let preview: string | undefined; + if (file.type.startsWith('image/')) { + preview = URL.createObjectURL(file); + } + return { file, preview, uploading: true }; + }); + + setSelectedFiles((prev) => [...prev, ...newItems]); + + pendingUploadCountRef.current += 1; + setPendingUploadCount((current) => current + 1); + try { + const uploadedFiles = await uploadChatFiles(filesToAdd, appId); + + setSelectedFiles((prev) => + prev.map((item) => { + const fileIndex = filesToAdd.indexOf(item.file); + const uploadedFile = fileIndex >= 0 ? uploadedFiles[fileIndex] : undefined; + if (uploadedFile) { + return { + ...item, + uploading: false, + uploadedId: uploadedFile.id, + uploadedFile, + }; + } + return item; + }), + ); + } catch (err) { + console.error('Failed to upload files:', err); + setSelectedFiles((prev) => prev.filter((item) => !filesToAdd.includes(item.file))); + } finally { + pendingUploadCountRef.current = Math.max(0, pendingUploadCountRef.current - 1); + setPendingUploadCount((current) => Math.max(0, current - 1)); + } + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleRemoveFile = (index: number) => { + setSelectedFiles((prev) => { + const item = prev[index]; + if (item?.preview) { + URL.revokeObjectURL(item.preview); + } + return prev.filter((_, i) => i !== index); + }); + }; + + const takeReadyFiles = (): MessageFile[] => + selectedFiles.flatMap((item) => (item.uploadedFile ? [item.uploadedFile] : [])); + + const clear = () => { + setSelectedFiles((prev) => { + for (const item of prev) { + if (item.preview) URL.revokeObjectURL(item.preview); + } + return []; + }); + }; + + return { + selectedFiles, + isUploading, + fileInputRef, + pendingUploadCountRef, + handleFileSelect, + handleRemoveFile, + takeReadyFiles, + clear, + hasReadyUpload: selectedFiles.some((item) => item.uploadedId), + atFileLimit: selectedFiles.length >= MAX_FILES, + }; +} diff --git a/web/app/src/hooks/useChat.ts b/web/app/src/features/chat/useChat.ts similarity index 58% rename from web/app/src/hooks/useChat.ts rename to web/app/src/features/chat/useChat.ts index 49aedf70c37..032bc1fc673 100644 --- a/web/app/src/hooks/useChat.ts +++ b/web/app/src/features/chat/useChat.ts @@ -1,14 +1,16 @@ 'use client'; -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useCallback, useMemo } from 'react'; +import { createSignal } from '@tschk/moonshine'; +import { useSignalValue } from '@/lib/signals'; import { getMessages, sendMessageStream, clearMessages as clearMessagesApi, saveRealtimeMessage, -} from '@/lib/api'; +} 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 { @@ -38,76 +40,63 @@ interface UseChatReturn { appendRealtimeExchange: (humanText: string, aiText: string) => Promise; } -export function useChat(options: UseChatOptions = {}): UseChatReturn { - const { appId, chatSessionId = null } = options; - - const [messages, setMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isStreaming, setIsStreaming] = useState(false); - const [streamingText, setStreamingText] = useState(''); - const [currentThinking, setCurrentThinking] = useState(''); - const [error, setError] = useState(null); +export function createChatStore() { + const messages = createSignal([]); + const isLoading = createSignal(false); + const isStreaming = createSignal(false); + const streamingText = createSignal(''); + const currentThinking = createSignal(''); + const error = createSignal(null); + let historyLoadedKey: string | null = null; - // Track current app + session to detect changes - const currentAppIdRef = useRef(appId); - const currentSessionIdRef = useRef(chatSessionId); - // Track if we've loaded history for the current app + session - const historyLoadedRef = useRef(null); + return { + messages, + isLoading, + isStreaming, + streamingText, + currentThinking, + error, + getHistoryLoadedKey: () => historyLoadedKey, + setHistoryLoadedKey: (key: string | null) => { + historyLoadedKey = key; + }, + }; +} - // A load or a stream belongs to the thread it was started for. Switching - // threads mid-flight must not let the previous thread's response land in the - // newly-selected one. +export function useChat(options: UseChatOptions = {}): UseChatReturn { + const { appId, chatSessionId = null } = options; + const store = useMemo(() => createChatStore(), [appId, chatSessionId]); const claimRequest = useRequestOwner(`${appId ?? ''}||${chatSessionId ?? ''}`); - // Reset state when the app or the selected session changes. Switching threads - // must clear the transcript: leaving the previous session's messages on - // screen reads as though they belong to the newly-selected chat. The - // transient stream state goes with it, for the same reason. - useEffect(() => { - if ( - currentAppIdRef.current !== appId || - currentSessionIdRef.current !== chatSessionId - ) { - currentAppIdRef.current = appId; - currentSessionIdRef.current = chatSessionId; - historyLoadedRef.current = null; - setMessages([]); - setError(null); - setStreamingText(''); - setCurrentThinking(''); - setIsStreaming(false); - setIsLoading(false); - } - }, [appId, chatSessionId]); + const messages = useSignalValue(store.messages); + const isLoading = useSignalValue(store.isLoading); + const isStreaming = useSignalValue(store.isStreaming); + const streamingText = useSignalValue(store.streamingText); + const currentThinking = useSignalValue(store.currentThinking); + const error = useSignalValue(store.error); - /** - * Load message history from server - */ const loadHistory = useCallback(async () => { const targetKey = `${appId ?? ''}||${chatSessionId ?? ''}`; - if (historyLoadedRef.current === targetKey) return; + if (store.getHistoryLoadedKey() === targetKey) return; const isCurrent = claimRequest(); - setIsLoading(true); - setError(null); + store.isLoading.set(true); + store.error.set(null); try { const history = await getMessages(appId, chatSessionId); if (!isCurrent()) return; - setMessages([...history].reverse()); - historyLoadedRef.current = targetKey; + store.messages.set([...history].reverse()); + store.setHistoryLoadedKey(targetKey); } catch (err) { if (!isCurrent()) return; console.error('Failed to load message history:', err); - setError('Failed to load message history'); + store.error.set('Failed to load message history'); } finally { - if (isCurrent()) setIsLoading(false); + if (isCurrent()) store.isLoading.set(false); } - }, [appId, chatSessionId, claimRequest]); + }, [appId, chatSessionId, claimRequest, store]); - /** - * Send a message and handle streaming response - */ const sendMessage = useCallback( async ( text: string, @@ -115,17 +104,14 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { context?: ChatContextInfo | null, optimisticFiles?: MessageFile[], ) => { - if ((!text.trim() && !fileIds?.length) || isStreaming) return; + if ((!text.trim() && !fileIds?.length) || store.isStreaming.peek()) return; const isCurrent = claimRequest(); - setError(null); - setIsStreaming(true); - setStreamingText(''); - setCurrentThinking(''); + store.error.set(null); + store.isStreaming.set(true); + store.streamingText.set(''); + store.currentThinking.set(''); - // Add user message to the list immediately (optimistic update). - // Client-only fields (`ask_for_nps`) live on ClientMessage; backend REST - // authority for messages is the generated `Message` schema. const createdAt = new Date().toISOString(); const uploadedFilesById = new Map( optimisticFiles?.map((file) => [file.id, file]) ?? [], @@ -151,7 +137,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { ask_for_nps: false, }; - setMessages((prev) => [...prev, userMessage]); + store.messages.set((prev) => [...prev, userMessage]); let accumulatedText = ''; @@ -159,35 +145,30 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { await sendMessageStream( text.trim(), (chunk: MessageChunk) => { - // Chunks that arrive after the reader moved to another thread - // belong to the thread they were requested for, not this one. if (!isCurrent()) return; switch (chunk.type) { case 'think': - setCurrentThinking((prev) => prev + chunk.text); + store.currentThinking.set((prev) => prev + chunk.text); break; case 'data': accumulatedText += chunk.text; - setStreamingText(accumulatedText); + store.streamingText.set(accumulatedText); break; case 'done': - // Replace streaming text with final message if (chunk.message) { - setMessages((prev) => [...prev, chunk.message!]); + store.messages.set((prev) => [...prev, chunk.message!]); } - setStreamingText(''); - setCurrentThinking(''); + store.streamingText.set(''); + store.currentThinking.set(''); break; case 'message': - // Handle related memory messages if needed - // chunk.message contains related memory data break; case 'error': - setError(chunk.text); + store.error.set(chunk.text); break; } }, @@ -196,9 +177,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { } catch (err) { if (!isCurrent()) return; console.error('Failed to send message:', err); - setError(err instanceof Error ? err.message : 'Failed to send message'); + store.error.set(err instanceof Error ? err.message : 'Failed to send message'); - // If we have accumulated text, add it as a partial message if (accumulatedText) { const partialMessage: ClientMessage = { id: `error-${Date.now()}`, @@ -211,39 +191,36 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { memories: [], ask_for_nps: false, }; - setMessages((prev) => [...prev, partialMessage]); + store.messages.set((prev) => [...prev, partialMessage]); } } finally { if (isCurrent()) { - setIsStreaming(false); - setStreamingText(''); + store.isStreaming.set(false); + store.streamingText.set(''); } } }, - [appId, chatSessionId, isStreaming, claimRequest], + [appId, chatSessionId, claimRequest, store], ); - /** - * Clear all message history - */ const clearHistory = useCallback(async () => { const isCurrent = claimRequest(); - setIsLoading(true); - setError(null); + store.isLoading.set(true); + store.error.set(null); try { await clearMessagesApi(appId, chatSessionId); if (!isCurrent()) return; - setMessages([]); - historyLoadedRef.current = null; + store.messages.set([]); + store.setHistoryLoadedKey(null); } catch (err) { if (!isCurrent()) return; console.error('Failed to clear messages:', err); - setError('Failed to clear message history'); + store.error.set('Failed to clear message history'); } finally { - if (isCurrent()) setIsLoading(false); + if (isCurrent()) store.isLoading.set(false); } - }, [appId, chatSessionId, claimRequest]); + }, [appId, chatSessionId, claimRequest, store]); const appendRealtimeExchange = useCallback( async (humanText: string, aiText: string) => { @@ -269,8 +246,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { app_id: appId, })); - setMessages((current) => [...current, ...optimistic]); - setError(null); + store.messages.set((current) => [...current, ...optimistic]); + store.error.set(null); try { for (const message of optimistic) { @@ -284,11 +261,11 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn { } } catch (err) { console.error('Failed to save live conversation:', err); - if (isCurrent()) setError('Live conversation was not saved to chat history'); + if (isCurrent()) store.error.set('Live conversation was not saved to chat history'); throw err; } }, - [appId, chatSessionId, claimRequest], + [appId, chatSessionId, claimRequest, store], ); return { diff --git a/web/app/src/hooks/useGeminiLive.ts b/web/app/src/features/chat/useGeminiLive.ts similarity index 59% rename from web/app/src/hooks/useGeminiLive.ts rename to web/app/src/features/chat/useGeminiLive.ts index a0f083db562..0233a497aef 100644 --- a/web/app/src/hooks/useGeminiLive.ts +++ b/web/app/src/features/chat/useGeminiLive.ts @@ -1,13 +1,25 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { TranscriptSegment } from '@/components/recording/RecordingContext'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { createSignal } from '@tschk/moonshine'; +import { useSignalValue } from '@/lib/signals'; +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'; +export function createGeminiLiveStore() { + const state = createSignal('idle'); + const level = createSignal(0); + const duration = createSignal(0); + const humanText = createSignal(''); + const aiText = createSignal(''); + const error = createSignal(null); + return { state, level, duration, humanText, aiText, error }; +} + export function useGeminiLive({ messages, onExchange, @@ -15,32 +27,34 @@ export function useGeminiLive({ messages: ClientMessage[]; onExchange: (humanText: string, aiText: string) => Promise; }) { - const [state, setState] = useState('idle'); - const [level, setLevel] = useState(0); - const [duration, setDuration] = useState(0); - const [humanText, setHumanText] = useState(''); - const [aiText, setAiText] = useState(''); - const [error, setError] = useState(null); + const store = useMemo(() => createGeminiLiveStore(), []); const clientRef = useRef(null); const messagesRef = useRef(messages); const exchangeRef = useRef(onExchange); messagesRef.current = messages; exchangeRef.current = onExchange; + const state = useSignalValue(store.state); + const level = useSignalValue(store.level); + const duration = useSignalValue(store.duration); + const humanText = useSignalValue(store.humanText); + const aiText = useSignalValue(store.aiText); + const error = useSignalValue(store.error); + useEffect(() => { if (state !== 'listening') return; - const interval = window.setInterval(() => setDuration((value) => value + 1), 1000); + const interval = window.setInterval(() => store.duration.set((value) => value + 1), 1000); return () => window.clearInterval(interval); - }, [state]); + }, [state, store]); useEffect(() => () => clientRef.current?.stop(), []); const start = useCallback(async () => { if (clientRef.current) return; - setError(null); - setDuration(0); - setLevel(0); - setState('connecting'); + store.error.set(null); + store.duration.set(0); + store.level.set(0); + store.state.set('connecting'); const history = messagesRef.current .slice(-20) .map((message) => ({ sender: message.sender, text: message.text })) @@ -53,34 +67,34 @@ export function useGeminiLive({ client = new GeminiLiveClient( { onReady: () => { - if (clientRef.current === client) setState('listening'); + if (clientRef.current === client) store.state.set('listening'); }, onLevel: (nextLevel) => { - if (clientRef.current === client) setLevel(nextLevel); + if (clientRef.current === client) store.level.set(nextLevel); }, onTranscript: (human, ai) => { if (clientRef.current !== client) return; - setHumanText(human); - setAiText(ai); + store.humanText.set(human); + store.aiText.set(ai); }, onExchange: (human, ai) => { void exchangeRef .current(human, ai) - .catch(() => setError('Live conversation was not saved to chat history')); + .catch(() => store.error.set('Live conversation was not saved to chat history')); }, onUsage: (report) => { void reportGeminiLiveUsage(report).catch(() => - setError('Gemini Live usage could not be recorded'), + store.error.set('Gemini Live usage could not be recorded'), ); }, onError: (message) => { - if (clientRef.current === client) setError(message); + if (clientRef.current === client) store.error.set(message); }, onClose: () => { if (clientRef.current !== client) return; clientRef.current = null; - setState('idle'); - setLevel(0); + store.state.set('idle'); + store.level.set(0); }, }, history, @@ -92,30 +106,30 @@ export function useGeminiLive({ client.connect(session.token); } catch (caught) { clientRef.current = null; - setState('idle'); - setError(caught instanceof Error ? caught.message : 'Could not start Gemini Live'); + store.state.set('idle'); + store.error.set(caught instanceof Error ? caught.message : 'Could not start Gemini Live'); } - }, []); + }, [store]); const stop = useCallback(() => { clientRef.current?.stop(); clientRef.current = null; - setState('idle'); - setLevel(0); - setHumanText(''); - setAiText(''); - }, []); + store.state.set('idle'); + store.level.set(0); + store.humanText.set(''); + store.aiText.set(''); + }, [store]); const pause = useCallback(() => { clientRef.current?.pause(); - setState('paused'); - setLevel(0); - }, []); + store.state.set('paused'); + store.level.set(0); + }, [store]); const resume = useCallback(() => { clientRef.current?.resume(); - setState('listening'); - }, []); + store.state.set('listening'); + }, [store]); const segments = useMemo(() => { const current: TranscriptSegment[] = []; 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..eff04f38edb --- /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 — people, list, recaps, detail, and search are moonshine signal stores + +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 87% rename from web/app/src/lib/__tests__/conversationTimeline.test.ts rename to web/app/src/features/conversations/__tests__/conversationTimeline.test.ts index c85eb38ee95..6cbfa1a2aa5 100644 --- a/web/app/src/lib/__tests__/conversationTimeline.test.ts +++ b/web/app/src/features/conversations/__tests__/conversationTimeline.test.ts @@ -6,8 +6,10 @@ import { dayKeyOf, dayLabel, flattenTimelineItems, + groupRecapsByMonth, parseLocalDay, -} from '@/lib/conversationTimeline'; + selectionFromSearchParams, +} from '../model'; import type { Conversation } from '@/types/conversation'; import type { DailySummary } from '@/types/recap'; @@ -198,3 +200,25 @@ 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' }); + }); +}); + +describe('groupRecapsByMonth', () => { + it('groups by local calendar month', () => { + const grouped = groupRecapsByMonth([ + recap('jan', '2025-01-15'), + recap('feb', '2025-02-01'), + recap('jan-2', '2025-01-02'), + ]); + expect(Object.keys(grouped)).toEqual(['January 2025', 'February 2025']); + expect(grouped['January 2025']?.map((item) => item.id)).toEqual(['jan', 'jan-2']); + }); +}); diff --git a/web/app/src/features/conversations/__tests__/useConversation.test.tsx b/web/app/src/features/conversations/__tests__/useConversation.test.tsx new file mode 100644 index 00000000000..56e938715ac --- /dev/null +++ b/web/app/src/features/conversations/__tests__/useConversation.test.tsx @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Conversation } from '@/types/conversation'; + +vi.mock('@/features/conversations/api', () => ({ + getConversation: vi.fn(), +})); + +const api = await import('@/features/conversations/api'); +const { useConversation } = await import('@/features/conversations/useConversation'); + +function conversation(id: string): Conversation { + return { + id, + created_at: '2026-01-01T00:00:00Z', + started_at: '2026-01-01T00:00:00Z', + structured: { title: id, overview: '', emoji: '', category: 'other' }, + } as unknown as Conversation; +} + +type Deferred = { promise: Promise; resolve: (value: T) => void }; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('useConversation', () => { + it('loads a conversation by id', async () => { + vi.mocked(api.getConversation).mockResolvedValue(conversation('c1')); + const { result } = renderHook(() => useConversation('c1')); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.conversation?.id).toBe('c1'); + }); + + it('applies a local update without refetching', async () => { + vi.mocked(api.getConversation).mockResolvedValue(conversation('c1')); + const { result } = renderHook(() => useConversation('c1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.update({ + ...conversation('c1'), + structured: { + title: 'Renamed', + overview: '', + emoji: '', + category: 'other', + }, + } as Conversation); + }); + + expect(result.current.conversation?.structured.title).toBe('Renamed'); + expect(api.getConversation).toHaveBeenCalledTimes(1); + }); + + it('does not land a previous id after the selection changes', async () => { + const first = deferred(); + const second = deferred(); + vi.mocked(api.getConversation) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const { result, rerender } = renderHook( + ({ id }: { id: string }) => useConversation(id), + { initialProps: { id: 'c-a' } }, + ); + + rerender({ id: 'c-b' }); + + await act(async () => { + second.resolve(conversation('c-b')); + await Promise.resolve(); + first.resolve(conversation('c-a')); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.conversation?.id).toBe('c-b')); + }); +}); diff --git a/web/app/src/features/conversations/__tests__/useConversations.test.tsx b/web/app/src/features/conversations/__tests__/useConversations.test.tsx new file mode 100644 index 00000000000..b04238a81ee --- /dev/null +++ b/web/app/src/features/conversations/__tests__/useConversations.test.tsx @@ -0,0 +1,46 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Conversation } from '@/types/conversation'; +import { clearAllCache } from '@/lib/cache'; + +vi.mock('@/features/conversations/api', () => ({ + getConversations: vi.fn(), +})); + +const api = await import('@/features/conversations/api'); +const { useConversations } = await import('@/features/conversations/useConversations'); + +function conversation(id: string): Conversation { + return { + id, + created_at: '2026-01-01T00:00:00Z', + started_at: '2026-01-01T00:00:00Z', + structured: { title: id, overview: '', emoji: '', category: 'other' }, + } as unknown as Conversation; +} + +beforeEach(() => { + vi.clearAllMocks(); + clearAllCache(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('useConversations', () => { + it('loads conversations on mount', async () => { + vi.mocked(api.getConversations).mockResolvedValue([conversation('c1')]); + const { result } = renderHook(() => useConversations()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.conversations.map((entry) => entry.id)).toEqual(['c1']); + expect(result.current.error).toBeNull(); + }); + + it('surfaces a load failure', async () => { + vi.mocked(api.getConversations).mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useConversations()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('network down'); + expect(result.current.conversations).toEqual([]); + }); +}); diff --git a/web/app/src/features/conversations/__tests__/usePeople.test.tsx b/web/app/src/features/conversations/__tests__/usePeople.test.tsx new file mode 100644 index 00000000000..70935e85768 --- /dev/null +++ b/web/app/src/features/conversations/__tests__/usePeople.test.tsx @@ -0,0 +1,56 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Person } from '@/types/user'; + +vi.mock('@/features/conversations/api', () => ({ + getPeople: vi.fn(), + createPerson: vi.fn(), + updatePersonName: vi.fn(), + deletePerson: vi.fn(), +})); + +const api = await import('@/features/conversations/api'); +const { usePeople } = await import('@/features/conversations/usePeople'); + +function person(overrides: Partial = {}): Person { + return { + id: 'p1', + name: 'Ada', + created_at: '2026-01-01T00:00:00Z', + speech_samples_count: 0, + ...overrides, + }; +} + +async function renderLoaded(initial: Person[] = [person()]) { + vi.mocked(api.getPeople).mockResolvedValue(initial); + const view = renderHook(() => usePeople()); + await waitFor(() => expect(view.result.current.loading).toBe(false)); + return view; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('usePeople', () => { + it('loads people on mount', async () => { + const { result } = await renderLoaded(); + expect(result.current.people.map((entry) => entry.name)).toEqual(['Ada']); + }); + + it('rolls a rename back when the server rejects it', async () => { + const { result } = await renderLoaded(); + vi.mocked(api.updatePersonName).mockRejectedValue(new Error('conflict')); + + let succeeded = true; + await act(async () => { + succeeded = await result.current.updatePerson('p1', 'Grace'); + }); + + expect(succeeded).toBe(false); + expect(result.current.people[0].name).toBe('Ada'); + expect(result.current.error).toBe('conflict'); + }); +}); diff --git a/web/app/src/features/conversations/__tests__/useRecaps.test.tsx b/web/app/src/features/conversations/__tests__/useRecaps.test.tsx new file mode 100644 index 00000000000..7bfd7c8de8d --- /dev/null +++ b/web/app/src/features/conversations/__tests__/useRecaps.test.tsx @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DailySummary } from '@/types/recap'; +import { clearAllCache } from '@/lib/cache'; + +vi.mock('@/features/conversations/api', () => ({ + getDailySummaries: vi.fn(), + getDailySummary: vi.fn(), + deleteDailySummary: vi.fn(), + generateTestDailySummary: vi.fn(), +})); + +const api = await import('@/features/conversations/api'); +const { useRecaps } = await import('@/features/conversations/useRecaps'); + +function recap(id: string, date = '2025-01-15'): DailySummary { + return { + id, + date, + headline: id, + day_emoji: '', + overview: '', + stats: { + total_conversations: 0, + total_duration_minutes: 0, + action_items_count: 0, + }, + highlights: [], + action_items: [], + unresolved_questions: [], + decisions_made: [], + knowledge_nuggets: [], + locations: [], + created_at: '2025-01-15T00:00:00Z', + }; +} + +async function renderLoaded(initial: DailySummary[] = [recap('r1')]) { + vi.mocked(api.getDailySummaries).mockResolvedValue(initial); + const view = renderHook(() => useRecaps()); + await waitFor(() => expect(view.result.current.loading).toBe(false)); + return view; +} + +beforeEach(() => { + vi.clearAllMocks(); + clearAllCache(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('useRecaps', () => { + it('loads recaps on mount', async () => { + const { result } = await renderLoaded(); + expect(result.current.recaps.map((entry) => entry.id)).toEqual(['r1']); + }); + + it('puts a recap back when delete fails', async () => { + const { result } = await renderLoaded(); + vi.mocked(api.deleteDailySummary).mockRejectedValue(new Error('locked')); + + let succeeded = true; + await act(async () => { + succeeded = await result.current.removeRecap('r1'); + }); + + expect(succeeded).toBe(false); + expect(result.current.recaps.map((entry) => entry.id)).toEqual(['r1']); + expect(result.current.error).toBe('locked'); + }); +}); diff --git a/web/app/src/features/conversations/__tests__/useSearchConversations.test.tsx b/web/app/src/features/conversations/__tests__/useSearchConversations.test.tsx new file mode 100644 index 00000000000..d7e9200acd1 --- /dev/null +++ b/web/app/src/features/conversations/__tests__/useSearchConversations.test.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Conversation } from '@/types/conversation'; + +vi.mock('@/features/conversations/api', () => ({ + searchConversations: vi.fn(), +})); + +const api = await import('@/features/conversations/api'); +const { useSearchConversations } = await import( + '@/features/conversations/useSearchConversations' +); + +function conversation(id: string): Conversation { + return { + id, + created_at: '2026-01-01T00:00:00Z', + started_at: '2026-01-01T00:00:00Z', + structured: { title: id, overview: '', emoji: '', category: 'other' }, + } as unknown as Conversation; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('useSearchConversations', () => { + it('replaces results on a new search', async () => { + vi.mocked(api.searchConversations).mockResolvedValue({ + items: [conversation('c1')], + current_page: 1, + total_pages: 2, + }); + + const { result } = renderHook(() => useSearchConversations()); + + await act(async () => { + await result.current.search('hello'); + }); + + expect(result.current.results.map((entry) => entry.id)).toEqual(['c1']); + expect(result.current.totalPages).toBe(2); + }); + + it('appends the next page using peek rather than a stale loading flag', async () => { + vi.mocked(api.searchConversations) + .mockResolvedValueOnce({ + items: [conversation('c1')], + current_page: 1, + total_pages: 2, + }) + .mockResolvedValueOnce({ + items: [conversation('c2')], + current_page: 2, + total_pages: 2, + }); + + const { result } = renderHook(() => useSearchConversations()); + + await act(async () => { + await result.current.search('hello'); + }); + await act(async () => { + await result.current.loadMore(); + }); + + expect(result.current.results.map((entry) => entry.id)).toEqual(['c1', 'c2']); + expect(result.current.currentPage).toBe(2); + }); + + it('clears without calling search', async () => { + vi.mocked(api.searchConversations).mockResolvedValue({ + items: [conversation('c1')], + current_page: 1, + total_pages: 1, + }); + const { result } = renderHook(() => useSearchConversations()); + await act(async () => { + await result.current.search('hello'); + }); + + act(() => { + result.current.clear(); + }); + + expect(result.current.results).toEqual([]); + expect(result.current.currentPage).toBe(1); + }); +}); diff --git a/web/app/src/features/conversations/api.ts b/web/app/src/features/conversations/api.ts new file mode 100644 index 00000000000..ef4a40f2a1f --- /dev/null +++ b/web/app/src/features/conversations/api.ts @@ -0,0 +1,683 @@ +import { getIdToken } from '@/lib/firebase'; +import { getWebDeviceIdHash } from '@/lib/clientDevice'; +import { + invalidateCache, + invalidateCacheKey, + invalidationPatterns, + fetchWithCache, + cacheKeys, + CACHE_TTL, +} from '@/lib/cache'; +import { + API_BASE_URL, + fetchAuthorizedBlob, + fetchWithAuth, + getAudioAuthHeaders, +} from '@/shared/api/client'; +import type { + Conversation, + ConversationScreenFrameSet, + ConversationSearchResponse, + ConversationStatus, + AudioFileUrlInfo, + ScreenFrameSharingUpdateRequest, +} 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); +} + +/** + * Get the approved screenshot set (banner + strip) for a conversation. + * Uses the same fetch-with-cache idiom as `getConversation`; a short TTL + * balances against the frame set's signed URLs expiring after 60 minutes. + */ +export async function getConversationScreenFrames( + conversationId: string, +): Promise { + return fetchWithCache( + cacheKeys.screenFrames(conversationId), + () => + fetchWithAuth( + `/v1/conversations/${conversationId}/screenshots`, + ), + { ttl: CACHE_TTL.SHORT }, + ); +} + +/** + * Delete a single screenshot. The server may promote another already- + * approved, already-persisted frame to banner (contract §8); the returned + * set is authoritative, so callers should replace their local state with it + * rather than trying to predict the promotion. + */ +export async function deleteScreenFrame( + conversationId: string, + frameId: string, +): Promise { + const result = await fetchWithAuth( + `/v1/conversations/${conversationId}/screenshots/${frameId}`, + { method: 'DELETE' }, + ); + invalidateCacheKey(cacheKeys.screenFrames(conversationId)); + return result; +} + +/** Delete every screenshot for a conversation (banner + strip). */ +export async function deleteAllScreenFrames( + conversationId: string, +): Promise { + const result = await fetchWithAuth( + `/v1/conversations/${conversationId}/screenshots`, + { method: 'DELETE' }, + ); + invalidateCacheKey(cacheKeys.screenFrames(conversationId)); + return result; +} + +/** + * Toggle whether this conversation's approved frames are visible on its + * public share link. Default for a new conversation is `enabled: true`. + */ +export async function patchScreenFrameSharing( + conversationId: string, + enabled: boolean, +): Promise { + const body: ScreenFrameSharingUpdateRequest = { enabled }; + const result = await fetchWithAuth( + `/v1/conversations/${conversationId}/screenshot-sharing`, + { method: 'PATCH', body: JSON.stringify(body) }, + ); + invalidateCacheKey(cacheKeys.screenFrames(conversationId)); + return result; +} + +/** + * 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