29 Commits
Author SHA1 Message Date
westfarn 1a434b208e Update SignIn tests for registration-gated SSO buttons.
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 2m23s
2026-08-05 09:02:58 -05:00
westfarn 508f11b558 Hide the sso buttons if registration is not allowed
Deploy Beta / unit-tests (push) Failing after 13s
Deploy Beta / deploy-beta (push) Skipped
Unit Tests / test (push) Failing after 12s
2026-08-05 08:59:01 -05:00
westfarn 9e1449f0db Add public account-deletion page for Google Play (#103) (#110)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m23s
## Summary
- Closes [#103](#103).
- Adds public SPA route `/account-deletion/` (no auth) with Hesychia / `ai.hesychia.chat` branding, in-app deletion steps, soft-delete data retention copy, and a contact path (30-day SLA) for users who cannot sign in.
- Documents Play Console **Account deletion URL** in `ANDROID.md` and `android/README.md` (`https://hesychia.ai/account-deletion/`, legacy host, HashRouter form).

## Notes
- Retention copy matches backend soft-delete (v1): no claim of full erasure / hard purge yet.
- Play Console field still needs to be set out-of-band after deploy.

## Test plan
- [ ] Open `/account-deletion/` signed out — page loads (no redirect to sign-in)
- [ ] Confirm steps match Account → Delete my account + email confirm
- [ ] Confirm retention sections are accurate vs soft-delete
- [ ] `npm run test:ci -- --testPathPattern=AccountDeletionPage`
- [ ] After beta deploy, set Play Console Account deletion URL to the public HTTPS URLReviewed-on: #110
2026-08-04 10:53:48 -07:00
westfarn 0aad5394e9 Default to THINKING; hide Sources card (#108) (#109)
Deploy Beta / deploy-beta (push) Successful in 2m22s
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
## Summary
- Closes [#108](#108) — hide Sources card; clicking inline `[n]` opens that source in a new tab.
- Follow-up to [#106](#106) / [#107](#107): default fixed `modelName` is now `THINKING` (not `FAST`).

## Test plan
- [ ] Sent messages use THINKING.
- [ ] Grounded answers show inline `[n]` markers but no Sources card.
- [ ] Clicking `[n]` opens the matching URL in a new tab.Reviewed-on: #109
2026-08-04 10:41:15 -07:00
westfarn 6ec239d2e0 Remove FAST/THINKING model dropdown from chat composer (#106) (#107)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m20s
## Summary
- Closes [#106](#106).
- Removes the FAST / THINKING `<select>` from the chat input bar.
- Continues to send a fixed `modelName` of `FAST` on WebSocket sends so the backend contract is unchanged.

## Test plan
- [ ] Open chat dashboard — composer shows attach + text field + send only (no FAST/THINKING dropdown).
- [ ] Send a message — stream still works.
- [ ] Send with attachment — still works.Reviewed-on: #107
2026-08-04 10:17:32 -07:00
westfarn 27b4fd7698 Settings toggle: use previous conversations as context / RAG (#66) (#105)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m22s
## Summary
- Closes [#66](#66) — Account Preferences toggle for previous-conversation context / RAG.
- Reads/writes `use_conversation_context` via `GET/POST /conversation_preferences` (backend [#33](ai_ml_operations/chat_backend#33)).
- Default off; MUI tooltip explains use of previous conversations; loading + error states with revert on failed update.

## Depends on
Backend PR for [chat_backend#33](ai_ml_operations/chat_backend#33) (merge/deploy backend first or together).

## Test plan
- [ ] Open `/account/` — toggle visible under Account Preferences, off by default
- [ ] Hover info icon — tooltip about previous conversations customizing experience
- [ ] Toggle on — persists after reload; toggle off — persists
- [ ] Simulate API failure — value reverts and error alert shown
- [ ] Disable while loading/savingReviewed-on: #105
2026-08-04 06:28:12 -07:00
westfarn fb3e138ea2 Fix MessageContext exhaustive-deps for CI builds
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m24s
Stabilize schedulePostStreamRefetch with useCallback so CI=true ESLint no longer fails beta deploy.
2026-08-04 07:05:55 -05:00
westfarn a7c5d1a2aa Drop docx export to fix CRA beta build
Unit Tests / test (push) Successful in 11s
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 2m28s
docx@9 ESM trips Babel during react-scripts build; keep PDF/CSV/XLSX/TXT.
2026-08-04 06:22:54 -05:00
westfarn 3313738990 Live activity indicator replacing typing dots (#96) (#104)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 2m16s
## Summary
- Closes [#96](#96).
- Parses versioned WS `status` frames in `MessageContext` (`activityStage` / `label` / `detail` / `history`).
- New `ActivityIndicator`: completed stages with checks, ~400ms min display, elapsed/still-working notes, a11y live region, reduced-motion, dots fallback when no frames.
- Wired through `ConversationDetailCard` + `AsyncDashboard2`; analytics `ACTIVITY_STAGE_COMPLETED`.

## Depends on
Backend status emission from [chat_backend PR](https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls) (`feat/62-p4-evals-63-agentic`). Without it, UI keeps three-dot fallback.

## Test plan
- [x] Jest: `wsFrames`, `ActivityIndicator`, `ConversationDetailCard`, `MessageContext`
- [ ] Manual light/dark + 320px width
- [ ] Manual with backend status frames: Evaluating → Searching → Refining
- [ ] Manual: stream interrupt clears indicator
- [ ] Capacitor Android smokeReviewed-on: #104
2026-08-04 04:08:57 -07:00
westfarn ce86f56d37 RevenueCat Capacitor IAP; keep Stripe on web (#100) (#102)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 2m20s
## Summary
- Native Capacitor: RevenueCat purchase/restore (`@revenuecat/purchases-capacitor`)
- Web: Stripe checkout/portal unchanged
- Billing history shows store rows (`provider` / `revenuecat_store`)
- RC `logIn`/`logOut` on auth lifecycle; appUserID = user pk

Closes #100. Depends on backend [chat_backend#68](ai_ml_operations/chat_backend#68) / monetization PR.

## Test plan
- [x] Unit tests: BillingSection, revenueCat, finance helpers, SignIn/Up, AuthCallback, Header2, DeleteAccount (38 OK)
- [ ] Fill `REACT_APP_REVENUECAT_*` keys in `.env.mobile`
- [ ] Align RC offerings/products with plan slugs
- [ ] `npm run build:mobile` + `cap sync`; sandbox purchase on Android/iOS
- [ ] Confirm web Stripe checkout/portal still work
- [ ] After RC webhook, Account → Billing history shows store invoiceReviewed-on: #102
2026-08-04 03:43:10 -07:00
westfarn 3858b97104 Message actions + citation Sources (#97, #98) (#101)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 2m10s
## Summary
- Closes [#97](#97) — message action row: copy (raw markdown + code-block copy), thumbs up/down with optimistic updates + down-reason popover, and export menu (PDF / DOCX / CSV / XLSX / TXT) for a single message plus conversation-level export in the chat toolbar.
- Closes [#98](#98) — versioned WS `citations` frame parsing, Sources list under assistant bubbles, clickable `[n]` markers, and history hydrate from `Prompt.citations`.
- Backend companion for ratings: [chat_backend#67](ai_ml_operations/chat_backend#67). Thumbs UI posts to `prompt_feedback` once that lands; votes rehydrate from `conversation_details.feedback`.
- Export libs (`pdfmake`, `docx`, `papaparse`, `xlsx`) are dynamically imported so they stay out of the main path until used.

## Test plan
- [ ] Stream a grounded answer → Sources appear after stream; inline `[n]` highlights the matching source; reload keeps Sources.
- [ ] Non-grounded turn → no Sources header.
- [ ] Copy message + code block; confirm checkmark ~2s; failure path shows toast.
- [ ] Thumbs up/down optimistic UI; clear by re-click; down opens reason popover without blocking the vote (needs chat_backend#67).
- [ ] Export one message and whole conversation in all five formats; check searchable PDF text and DOCX structure.
- [ ] Action row: hover reveal on desktop, always visible on last/touch; hidden while streaming.
- [ ] `npm test -- --testPathPattern='wsFrames|clipboard|exportChat|promptFeedback|ConversationDetailCard'`Reviewed-on: #101
2026-08-04 03:32:17 -07:00
westfarn 5be66dbaa7 iOS App (#99)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m4s
closes #21Reviewed-on: #99
2026-08-03 12:14:21 -07:00
westfarn 4501ee8f3d Docs sync progress, Personal/Company list UX, Analytics heatmap (#92–#94) (#95)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 2m0s
## Summary
- **#92** — Drive sync progress bar (determinate when total known; indeterminate while listing).
- **#93** — Documents list: Personal vs Company title, company helper copy only on company scope, Personal|Company tabs for company members, search/sort/pagination.
- **#94** — Analytics overhaul: Your activity (heatmap + volume charts) vs Company section for managers/members.

Depends on backend PR for #59/#60 + heatmap API.

## Test plan
- [x] Jest: DocumentStoragePage, Analytics, drive utils
- [ ] Manual: Sync Now shows progress N/M
- [ ] Manual: company user tabs Personal/Company; personal-only users see Personal documents only
- [ ] Manual: Analytics shows heatmap under Your activity

Closes #92
Closes #93
Closes #94Reviewed-on: #95
2026-08-02 04:50:20 -07:00
westfarn 6c96c50d99 Drive Sync now pending UX + error alerts (#90) (#91)
Deploy Beta / deploy-beta (push) Successful in 1m59s
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
## Summary
- Closes [#90](#90)
- Depends on backend [chat_backend#57](ai_ml_operations/chat_backend#57) (202 + pending status)
- Sync now: enqueue, poll until status leaves `pending`, show **error/success alert**
- Per-connection `last_sync_error` shown when status is `error`
- OAuth green “Drive connected” banner unchanged (connect-only)
- Refreshes document list on successful sync

## Test plan
- [x] `drive.test` + `DocumentStoragePage.test`
- [ ] Manual with backend #57: Sync fails (Drive API off) → red alert with truncated error
- [ ] Manual: Sync succeeds → success alert + docs refresh
- [ ] Manual: Syncing… / button disabled while pendingReviewed-on: #91
2026-08-02 04:07:22 -07:00
westfarn d5a885f42e Mobile Conversations toggle in header + theme-aware agent bubbles (#87, #88) (#89)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 2m0s
## Summary
- Closes [#87](#87) — move mobile Conversations toggle inline beside the Hesychia logo in the header (no overlay over chat bubbles)
- Closes [#88](#88) — agent message bubbles follow light/dark theme with correct text contrast; user bubbles unchanged

## Changes
- `Header2`: optional `onOpenConversations` renders compact mobile toggle next to brand; label hides on very narrow screens to avoid crowding the nav hamburger
- `AsyncDashboard2`: wire drawer open via header callback; remove floating `MobileSidebarToggle`
- `ConversationDetailCard`: light-mode agent bubble uses light background + theme text color; dark mode keeps dark translucent bubble + light text

## Test plan
- [ ] Mobile: Conversations control appears beside Hesychia logo; opens/closes drawer as before
- [ ] Mobile: chat message text no longer obscured by floating Conversations chip
- [ ] Narrow phone width: header usable with logo + toggle + hamburger (no overflow)
- [ ] Light mode: agent bubbles light with dark text; user bubbles still gradient
- [ ] Dark mode: agent bubbles dark with light text; readability OK on long replies
- [ ] Unit: `Header2` + `ConversationDetailCard` tests passReviewed-on: #89
2026-08-02 03:39:35 -07:00
westfarn bfed056a0e Documents RAG gate + Drive connect UI (#81-#85) (#86)
Unit Tests / test (push) Successful in 11s
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m58s
## Summary

Companion to [chat_backend#42](ai_ml_operations/chat_backend#42).

- **#81:** Documents nav + page gated on `plan.features.rag` / `all_future_features`; upgrade CTA for Standard
- **#82:** Upload refreshes list; active toggle via PATCH; clearer company docs labeling
- **#83:** Personal Drive connect (JWT → authorize_url JSON → IdP), list/disconnect/sync/resource ids
- **#84:** Company manager knowledge-source section (`link_company_drive`)
- **#85:** Chat upgrade bubble when WS returns RAG `feature_not_allowed`

## Test plan

- [x] `npm test -- --watchAll=false` (157 OK)
- [ ] Standard plan: Documents hidden; upgrade card if navigated directly
- [ ] Founders/Pro: Documents visible; upload + active toggle
- [ ] Connect Drive (needs BE #42); banner on `?drive_connected=1`
- [ ] Company manager sees company section; member does not
- [ ] RAG-denied chat shows upgrade messageReviewed-on: #86
2026-08-01 14:01:13 -07:00
westfarn 4b685df489 Align Google/Microsoft SSO buttons with brand guidelines (#79) (#80)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m59s
## Summary
- Closes [#79](#79)
- Update `SsoButtons` with official Google multicolor "G" (on white) and Microsoft four-square logo
- Apply Google dark-theme button colors (`#131314` / `#8E918F` / `#E3E3E3`) and Microsoft dark button styling
- Use approved labels: `Continue with Google` / `Sign up with Google`, `Sign in with Microsoft`

## Test plan
- [ ] Sign In with Google + Microsoft OAuth enabled → both buttons show logos + correct labels
- [ ] Sign Up → Google shows "Sign up with Google"; Microsoft shows "Sign in with Microsoft"
- [ ] Click each button still starts existing OAuth redirect flow
- [ ] Dark card contrast looks correct; Google "G" remains multicolor on white
- [ ] `npm test -- --watchAll=false --testPathPattern=SignIn.test --testPathPattern=SsoButtons.test` passesReviewed-on: #80
2026-08-01 13:04:51 -07:00
westfarn 70dfa85d22 Show Beta Hesychia brand name on beta deploy (#77) (#78)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m56s
## Summary
- Closes [#77](#77)
- Sidenav `brandName` becomes **Beta Hesychia** when `REACT_APP_DEPLOY_ENV=beta`; otherwise stays **Hesychia**

## Test plan
- [ ] Local/default build: sidenav shows `Hesychia`
- [ ] Beta build (`.env.beta` / `REACT_APP_DEPLOY_ENV=beta`): sidenav shows `Beta Hesychia`
- [ ] Production build: sidenav still shows `Hesychia`Reviewed-on: #78
2026-08-01 12:34:26 -07:00
westfarn 19698658b1 Add subscription upgrade/change/cancel UX and account self-delete (#75) (#76)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m55s
## Summary
- Closes [#75](#75)
- Companion for [chat_backend#34](ai_ml_operations/chat_backend#34) (self-delete API)
- Account Billing: Upgrade / Change plan / Cancel CTAs (Stripe portal–first), plan picker when multiple public selectable plans exist, cancel confirmation with period-end messaging, complimentary users get no fake cancel
- Usage card Upgrade scrolls/focuses Billing; analytics for upgrade/change/cancel intents
- Danger Zone: email-confirmed `DELETE /user/` then logout → `/signin/`

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern='BillingSection|DeleteAccountSection'`
- [ ] Paid Stripe user: Upgrade / Change plan / Cancel → portal (or checkout for higher selectable plan)
- [ ] Cancel confirm shows period-end access copy; scheduled cancel notice when `cancel_at_period_end`
- [ ] Backer/admin: complimentary message, no Cancel/Upgrade
- [ ] Delete account: confirm email → soft-delete → signed out at `/signin/`Reviewed-on: #76
2026-08-01 12:24:10 -07:00
westfarn 5a847b64cc Move plan/quota UI to Account; Gemini-style usage card (#73) (#74)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m55s
## Summary
- Closes [#73](#73)
- Remove per-message `Tokens in … · out …` under assistant bubbles
- Remove dashboard `UsageSummaryBar` (plan / prompts left / period tokens)
- Add Account **Usage limit** card (Gemini-style): plan name, prompt progress meter with rolling-window reset hint, monthly tokens meter when capped (else period in/out), upgrade hint pointing at Billing

## Test plan
- [ ] Chat dashboard: no usage bar above messages
- [ ] Assistant messages: no token footer
- [ ] Account: Usage limit card shows plan + prompt bar (+ token bar when monthly quota set)
- [ ] `npm test -- --watchAll=false --testPathPattern=ConversationDetailCard` passes

Related follow-up: [#75](#75) upgrade/change/cancel subscription.Reviewed-on: #74
2026-07-31 07:08:06 -07:00
westfarn c6bacbb23c Fix BillingSection test mock types so beta build compiles
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m57s
Default subscription mock inferred plan as null; widen to union so
foundersSubscription can be passed. Unblocks CRA production build.
2026-07-31 06:29:45 -05:00
westfarn e89ccbbeca Show plan quotas and token usage in chat + billing UI (#72)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Failing after 1m50s
## Summary
Frontend companion to backend [#16](ai_ml_operations/chat_backend#16) / [#17](ai_ml_operations/chat_backend#17) / [#36](ai_ml_operations/chat_backend#36).

- **Token display (#16)**: assistant messages show `Tokens in … · out …`; missing usage renders as `—` (never fabricated 0).
- **Plan + quota UI (#17/#36)**: Account `BillingSection` loads `GET /finance/subscription/` (plan name, prompt remaining, period tokens). Chat dashboard `UsageSummaryBar` shows the same snapshot.
- **Backer path**: Sign-up skips Stripe checkout when `needs_checkout` is false.

Requires backend PR on `feature/plans-quotas-token-usage`.

## Test plan
- [ ] With subscribed user: usage bar + billing show plan and prompt remaining
- [ ] Assistant messages with null tokens show `—`; known counts render numbers
- [ ] Backer signup does not redirect to Stripe
- [ ] Founders signup still starts checkout
- [ ] Jest: `BillingSection`, `ConversationDetailCard`, `finance` helpers passReviewed-on: #72
2026-07-31 04:24:14 -07:00
westfarn 6a6e911ef8 Add Account Billing section + Stripe Customer Portal CTA (#33) (#71)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 1m58s
## Summary
- Closes [#33](#33)
- Add Billing glass cards on `/account/` (`Account2`): current plan summary + billing history from `GET /finance/invoices/` (and payments fallback)
- **Manage subscription** CTA → `POST /finance/portal/` → redirect to Stripe hosted Customer Portal (return URL `/account/`)
- Empty / unpaid users get **Complete payment** → Checkout instead of a dead portal button
- Visible errors for list/portal failures; fire `BILLING_PORTAL_OPENED`
- Depends on backend portal endpoint: [chat_backend#35](ai_ml_operations/chat_backend#35)

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern='(BillingSection|finance\\.test)'`
- [ ] Signed-in user with paid invoices: plan + history render; Manage subscription redirects to portal URL
- [ ] User with no invoices: empty copy + Complete payment starts Checkout
- [ ] Portal/list API errors show alert text (no silent fail)
- [ ] Return from Stripe portal lands on `/account/`
- [ ] No card fields collected in-appReviewed-on: #71
2026-07-31 03:54:07 -07:00
westfarn 553d526862 Fix brace-expansion lock for npm 10 beta deploy (#70)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m57s
## Summary
- Beta deploy after #69 failed: `Cannot find module 'brace-expansion'` during `npm run build:beta`
- Root cause: `package-lock.json` linked `brace-expansion` to empty phantom paths (`minimatch/vendor/brace-expansion-compat`). npm 11 remapped them locally; deploy Node 22 / npm 10 followed the lock literally
- Declare vendored shim as a direct dependency and rewrite lock links to `vendor/brace-expansion-compat`

## Test plan
- [x] `npm ci` under Node 22.23.1 / npm 10.9.8 resolves `brace-expansion` from fork-ts-checker nested minimatch
- [x] `npx react-scripts build` with `.env.beta` succeeds
- [x] `npm audit` still 4 moderate (0 critical / 0 high)
- [ ] Redeploy `chat_web_app` beta after mergeReviewed-on: #70
2026-07-30 16:57:17 -07:00
westfarn 44f412fffa Remediate llm-fe npm audit critical/high vulnerabilities (#69)
Unit Tests / test (push) Successful in 11s
Deploy Beta / unit-tests (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 51s
## Summary
- Closes #53
- `npm audit` in `llm-fe`: **103 → 4** (0 critical, 0 high; 4 moderate remain)
- Applied `npm audit fix`, bumped direct floors (`axios`, `react-router-dom`), pinned `styled-components@6.1.15` for CRA typecheck stability
- Added npm `overrides` for vulnerable transitive leaves (`tar`, `postcss`, `svgo`, `sharp`, `ws`, `flatted`, etc.)
- Vendored CRA-compatible `brace-expansion@5.0.8` shim (v1 default-export API + GHSA-mh99 DoS limits)

## Remaining (documented)
- **moderate:** `react-router`/`react-router-dom` — needs v7 (breaking)
- **moderate:** `webpack-dev-server` via `react-scripts` — CRA incompatible with patched WDS 5.2.6+; needs CRA → Vite (or similar) follow-up
- Deprecation warnings from CRA/`eslint@8`/old `glob` tooling still present until toolchain migrate

## Test plan
- [x] `npm ci` in `llm-fe`
- [x] `npm audit` → 0 critical / 0 high
- [x] `npm run build`
- [x] `npm run test:ci` → 25 suites / 112 tests passedReviewed-on: #69
2026-07-29 17:41:00 -07:00
westfarn b5e16bfa6e Rename Android application ID to ai.hesychia.chat (#68)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 3m32s
## Summary
- Closes #67
- Renames Android/Capacitor identity from `com.aimloperations.chat` → `ai.hesychia.chat`
- Updates `appId`, Gradle `applicationId`/`namespace`, `MainActivity` package path, `strings.xml` scheme, and Android README

## Notes
- New application ID installs as a separate app beside any old-ID install (expected for early builds).
- App already built and run on a physical device with this identity work in progress.

## Test plan
- [ ] `npx cap sync android` after web build
- [ ] Debug install on device — package shows `ai.hesychia.chat`
- [ ] App launches and reaches login / chat
- [ ] Confirm old `com.aimloperations.chat` install (if present) is separate and can be uninstalledReviewed-on: #68
2026-07-29 11:19:20 -07:00
westfarn 235c73dda8 Point beta FE at beta.backend.hesychia.ai (#64) (#65)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m7s
## Summary
- Closes #64
- Beta FE was still calling `beta.chatbackend.aimloperations.com`, which no longer resolves, so login failed with Axios `ERR_NETWORK`
- Point REST/WS env URLs at the live backend: `beta.backend.hesychia.ai`

## Test plan
- [ ] Redeploy beta FE with updated `.env.beta`
- [ ] Confirm built bundle `baseURL` is `https://beta.backend.hesychia.ai/api/`
- [ ] Sign in on https://beta.hesychia.ai succeeds (no `ERR_NETWORK`)
- [ ] Chat WebSocket connects via `wss://beta.backend.hesychia.ai/ws/chat_again/`Reviewed-on: #65
2026-07-29 05:53:25 -07:00
westfarn f22441371b Surface render crashes and make chat layout viewport-safe (#61) (#63)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 3m5s
## Summary
- Closes #61
- Replaces #62, which was opened from a branch that still carried the drawer commit already merged in #60 and therefore conflicted. Same change, cherry-picked cleanly onto current `master`.
- **Makes the mobile white screen diagnosable.** The app had **no error boundary anywhere**, so any throw in the dashboard unmounted the whole tree and left an empty `#root`. That matches the reported behavior exactly: brief flash, white page, other routes fine on a fresh load, blank again when navigating back to `/`. `AppErrorBoundary` renders the error name, message, stack, URL and user agent with Reload / Copy details buttons, so the real exception can be read and copied **from the phone**. It is keyed on the route path, so navigating away clears a crash.
- **Responsive layout fixes** for the clipping visible in mobile emulation: `100vw` → `100%`, `100dvh` for the chat shell so mobile URL bars can't push the input row off screen, `min-width: 0` / `flex-shrink` on the input row so attach, model select, textarea and send compress instead of overflowing, `env(safe-area-inset-*)` padding on header, drawer and input area, and message bubbles that wrap long tokens (`overflow-wrap: anywhere`, `max-width` on `pre`/`img`/`table`).
- **Perf:** particle canvas pauses while the tab is hidden, honors `prefers-reduced-motion`, caps particle count for the O(n²) link pass, and re-seeds only on real size changes. Chat auto-scroll now fires on message change rather than on every render.

## Test plan
- [x] `npm run test:ci` — 25 suites / 112 tests pass (adds `AppErrorBoundary` coverage for the pass-through and crash paths)
- [x] `npm run build` — clean
- [ ] Phone browser: sign in and land on `/` — chat renders, or the error card shows the real exception (paste into #61)
- [ ] Phone browser: no horizontal scroll; attach, model select, textarea and send all visible
- [ ] Phone browser: input row still reachable with the keyboard open
- [ ] Long code block / unbroken string stays inside the bubble
- [ ] Desktop: sidebar, chat and input unchanged; background animation still runs
- [ ] Background the tab and return — animation resumes

## Notes
The crash is not reproducible in desktop mobile emulation, so this ships the boundary as the diagnostic path instead of guessing at the throwing call site. Once the error card appears on the phone, the stack should point straight at the cause. Worth landing before the Capacitor Android wrap (#20), since the same failure mode is much harder to inspect inside a WebView.Reviewed-on: #63
2026-07-29 05:43:16 -07:00
westfarn ea40700fd3 Improve mobile conversation drawer layering and empty state (#59) (#60)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 3m3s
## Summary
- Closes #59
- Mobile conversation drawer is now a fixed sheet with stronger stacking over dashboard content
- Adds explicit close (X) control and empty-state copy when there are no conversations
- Keeps existing close-on-select / New Chat behavior

## Test plan
- [ ] Mobile: open Conversations drawer — sheet + overlay appear above content
- [ ] Tap X or overlay — drawer closes
- [ ] Empty conversation list shows guidance text
- [ ] Select conversation / New Chat closes drawer and chat still works
- [ ] Desktop sidebar layout unchangedReviewed-on: #60
2026-07-29 05:04:33 -07:00
167 changed files with 12467 additions and 2329 deletions
+3 -4
View File
@@ -1,8 +1,7 @@
# Beta API hosts (current). Target public product domain: hesychia.ai # Beta API hosts public product domain hesychia.ai
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
REACT_APP_DEPLOY_ENV=beta REACT_APP_DEPLOY_ENV=beta
REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.backend.hesychia.ai/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.backend.hesychia.ai/ws/chat_again/
# Tianji website id for Hesychia beta (distinct from prod so analytics stay separate). # Tianji website id for Hesychia beta (distinct from prod so analytics stay separate).
# Create "Hesychia Beta" in Tianji UI (workspace cm7w8087y020lddswyhamadj2), then paste id here. # Create "Hesychia Beta" in Tianji UI (workspace cm7w8087y020lddswyhamadj2), then paste id here.
REACT_APP_TIANJI_WEBSITE_ID=cms38bw671mf9n5jjw3xp1j3q REACT_APP_TIANJI_WEBSITE_ID=cms38bw671mf9n5jjw3xp1j3q
+6
View File
@@ -4,3 +4,9 @@
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ # REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired.
REACT_APP_REVENUECAT_APPLE_API_KEY=
REACT_APP_REVENUECAT_GOOGLE_API_KEY=
# Optional: pin a specific offering identifier (defaults to current offering).
# REACT_APP_REVENUECAT_OFFERING_ID=
+12
View File
@@ -23,6 +23,18 @@
*.keystore *.keystore
*.jks *.jks
# Capacitor / iOS CocoaPods + build artifacts (keep ios/ source; ignore Pods / DerivedData)
/ios/App/Pods/
/ios/App/build/
/ios/App/output/
/ios/App/App/public/
/ios/DerivedData/
/ios/**/xcuserdata/
*.mobileprovision
*.p12
*.cer
ExportOptions.plist
# misc # misc
.DS_Store .DS_Store
.env.local .env.local
+12 -1
View File
@@ -20,7 +20,18 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` | | Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` | | Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — | | ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | *(pending #33 Account portal CTA)* | — | | Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` |
| Subscription Upgrade Started | `SUBSCRIPTION_UPGRADE_STARTED` | BillingSection, UsageSummaryCard | `{ source, plan_slug? }` |
| Plan Change Started | `PLAN_CHANGE_STARTED` | BillingSection | `{ source, plan_slug? }` |
| Subscription Cancel Started | `SUBSCRIPTION_CANCEL_STARTED` | BillingSection | `{ source: 'portal' }` |
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — |
| Message Copied | `MESSAGE_COPIED` | MessageActions | `{ role }` |
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
## Identify ## Identify
+29 -4
View File
@@ -1,9 +1,10 @@
# Android (Capacitor) # Android (Capacitor)
Ship the CRA web build as an Android app via [Capacitor](https://capacitorjs.com/). Ship the CRA web build as an Android app via [Capacitor](https://capacitorjs.com/).
One codebase: `llm-fe/` → web + Android WebView. One codebase: `llm-fe/` → web + Android + iOS WebView.
**Build & Play deploy steps:** [`android/README.md`](android/README.md). **Build & Play deploy steps:** [`android/README.md`](android/README.md).
**iOS sibling:** [`IOS.md`](IOS.md) / [`ios/README.md`](ios/README.md).
Prerequisites for blockers already merged: JWT-only auth (#22), WebSocket lifecycle (#23), HashRouter (#24). Prerequisites for blockers already merged: JWT-only auth (#22), WebSocket lifecycle (#23), HashRouter (#24).
@@ -39,6 +40,20 @@ npm run android:sync
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`. Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
### RevenueCat IAP (#100)
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
| Variable | Purpose |
|----------|---------|
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
## Versioning ## Versioning
In `android/app/build.gradle`: In `android/app/build.gradle`:
@@ -63,6 +78,8 @@ Regenerate Android densities after changing source art:
npm run assets:generate npm run assets:generate
``` ```
Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
## Signing (upload keystore) ## Signing (upload keystore)
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager). **Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
@@ -115,12 +132,20 @@ Out of band ops (not automated here):
1. Google Play developer account 1. Google Play developer account
2. Privacy policy URL (product site / legal) 2. Privacy policy URL (product site / legal)
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView) 3. **Account deletion URL** (Data safety / Account deletion) → public page:
4. Screenshots for phone (+ tablet if targeting) - Canonical: `https://hesychia.ai/account-deletion/`
5. Upload `.aab` to **internal testing** track before production - Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
- Capacitor HashRouter: `/#/account-deletion/`
4. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
5. Screenshots for phone (+ tablet if targeting)
6. Upload `.aab` to **internal testing** track before production
In-app delete remains Account → **Delete my account** (`DELETE /api/user/`). The public URL
above is for Play reviewers and users who never open the app (#103).
## Related issues ## Related issues
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap - [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding) - [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding)
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing - [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
- [#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103) — Play public account-deletion URL
+111
View File
@@ -0,0 +1,111 @@
# iOS (Capacitor)
Ship the CRA web build as an iOS app via [Capacitor](https://capacitorjs.com/)
WKWebView. One codebase: `llm-fe/` → web + Android + iOS.
**Build & Xcode steps:** [`ios/README.md`](ios/README.md).
Prerequisites already merged: JWT-only auth (#22), WebSocket lifecycle (#23),
HashRouter (#24), Android Capacitor scaffolding (#20).
## Prerequisites
- **macOS** with **Xcode** 15+ (CI runners are Linux — iOS builds are **manual**
on a Mac until a hosted macOS runner is added)
- **Node.js 20** (matches CI; Capacitor **7.x**)
- **CocoaPods** (`sudo gem install cocoapods` or Homebrew `pod`)
- Apple Developer Program membership for device / TestFlight / App Store signing
(simulator debug runs without a paid membership using automatic signing)
## One-time / day-to-day
```bash
cd llm-fe
npm ci
npm run build:mobile # .env.mobile → CRA build → cap sync (android + ios)
npm run ios:open # opens ios/App/App.xcworkspace in Xcode
```
Or sync only after an existing `build/`:
```bash
npm run ios:sync
```
**Always open the `.xcworkspace`**, not the `.xcodeproj` (CocoaPods).
## Environment
Same as Android — see [`ANDROID.md`](ANDROID.md) § Environment.
| File | Purpose |
|------|---------|
| `.env.mobile` | Endpoints baked into the **mobile** bundle (`build:mobile`) |
| `.env.mobile.local` | Optional gitignored override |
Default `.env.mobile` points at production (`chatbackend.aimloperations.com`).
WebView origin is `capacitor://localhost`. Backend CORS/CSRF already trusts it
(`chat_backend` `CAPACITOR_WEBVIEW_ORIGINS`). Auth is header JWT — no cookies.
## Versioning
In Xcode target **App** → General, or `ios/App/App.xcodeproj/project.pbxproj`:
| Field | Xcode / build setting | Align with |
|-------|----------------------|------------|
| Version | `MARKETING_VERSION` (`CFBundleShortVersionString`) | `package.json` `version` + Android `versionName` (currently `0.1.0`) |
| Build | `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | Android `versionCode` (integer; **must increase** every TestFlight / App Store upload) |
## Icons / splash
Source art in `llm-fe/assets/` (same as Android):
- `icon.png` / `splash.png` / `splash-dark.png`
Regenerate iOS + Android densities:
```bash
npm run assets:generate
```
## Signing
**Never commit certificates, profiles, or `.p12` files.**
1. Xcode → Signing & Capabilities → Team (Automatic for debug / internal).
2. Bundle ID: **`ai.hesychia.chat`** (matches Android `applicationId` / Capacitor `appId`).
3. For distribution: App Store Connect app, distribution cert, provisioning profile
— keep credentials outside the repo (password manager / CI secrets later).
## Manual QA checklist
On **simulator and physical iPhone**:
- [ ] Login / logout / password reset
- [ ] Chat streaming + markdown / code blocks (selection + copy)
- [ ] Charts (`recharts`)
- [ ] Dark / light theme
- [ ] Keyboard does not cover compose input; notch / Dynamic Island / home indicator OK
- [ ] No rubber-band overscroll on chat shell
- [ ] Background → resume keeps or reconnects WebSocket (`appStateChange` + visibility)
- [ ] Cellular ↔ WiFi switch reconnects socket
- [ ] Airplane mode → restore reconnects
## TestFlight / App Store (follow-on)
Out of band (not automated here; separate deploy tickets):
1. Apple Developer Program + App ID `ai.hesychia.chat`
2. Privacy policy URL + App Privacy questionnaire
3. In-app account deletion if sign-up is offered (guideline)
4. Enough native shell value that review does not reject as 4.2 “repackaged website”
5. Archive → Upload → TestFlight internal → App Store
Push notifications, offline cache, Sign in with Apple — out of scope for this wrap.
## Related issues
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — this iOS wrap
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — Android scaffolding
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
+40
View File
@@ -0,0 +1,40 @@
# Monetization (web Stripe + native RevenueCat)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
## App user id
RevenueCat `appUserID` = Django user pk as string.
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
Backend webhook resolver accepts numeric pk (or email fallback).
## Mobile env keys
In `.env.mobile`:
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
## Auth hooks
- `Purchases.configure` once on native (first purchase / logIn).
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
- `logOut` on Header2 sign-out and Delete account.
## Related
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+12 -6
View File
@@ -1,6 +1,6 @@
# Hesychia — Android # Hesychia — Android
Native Capacitor project for `com.aimloperations.chat`. Web UI comes from the CRA Native Capacitor project for `ai.hesychia.chat`. Web UI comes from the CRA
`build/` output in the parent `llm-fe/` package (synced with `npx cap sync`). `build/` output in the parent `llm-fe/` package (synced with `npx cap sync`).
Broader Capacitor notes (env files, icons, QA): [`../ANDROID.md`](../ANDROID.md). Broader Capacitor notes (env files, icons, QA): [`../ANDROID.md`](../ANDROID.md).
@@ -105,11 +105,17 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
## Deploy to Google Play ## Deploy to Google Play
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`com.aimloperations.chat`). 1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
2. Complete store listing: privacy policy URL, data-safety form, screenshots. 2. Complete store listing: privacy policy URL, data-safety form, screenshots.
3. **Testing → Internal testing** → create release → upload `app-release.aab`. 3. Set **Account deletion URL** to the public instructions page (no sign-in required):
4. Add testers, roll out internal track, verify on devices. - `https://hesychia.ai/account-deletion/` (preferred product host)
5. Promote to closed / open / production only after internal QA passes. - or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
4. **Testing → Internal testing** → create release → upload `app-release.aab`.
5. Add testers, roll out internal track, verify on devices.
6. Promote to closed / open / production only after internal QA passes.
See also [`../ANDROID.md`](../ANDROID.md) Play checklist and issue
[#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103).
Each new upload needs a higher `versionCode`. Each new upload needs a higher `versionCode`.
@@ -126,6 +132,6 @@ Each new upload needs a higher `versionCode`.
| | | | | |
|--|--| |--|--|
| Application ID | `com.aimloperations.chat` | | Application ID | `ai.hesychia.chat` |
| Display name | Hesychia | | Display name | Hesychia |
| Min / target SDK | 23 / 35 (see `variables.gradle`) | | Min / target SDK | 23 / 35 (see `variables.gradle`) |
+2 -2
View File
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
namespace "com.aimloperations.chat" namespace "ai.hesychia.chat"
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.aimloperations.chat" applicationId "ai.hesychia.chat"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
@@ -1,4 +1,4 @@
package com.aimloperations.chat; package ai.hesychia.chat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

@@ -2,6 +2,6 @@
<resources> <resources>
<string name="app_name">Hesychia</string> <string name="app_name">Hesychia</string>
<string name="title_activity_main">Hesychia</string> <string name="title_activity_main">Hesychia</string>
<string name="package_name">com.aimloperations.chat</string> <string name="package_name">ai.hesychia.chat</string>
<string name="custom_url_scheme">com.aimloperations.chat</string> <string name="custom_url_scheme">ai.hesychia.chat</string>
</resources> </resources>
+10 -1
View File
@@ -1,16 +1,25 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.aimloperations.chat', appId: 'ai.hesychia.chat',
appName: 'Hesychia', appName: 'Hesychia',
webDir: 'build', webDir: 'build',
server: { server: {
// Bundled assets; HashRouter (#24) handles deep links without a rewrite server. // Bundled assets; HashRouter (#24) handles deep links without a rewrite server.
// Android: https://localhost — iOS: capacitor://localhost (CORS trusted in chat_backend).
androidScheme: 'https', androidScheme: 'https',
iosScheme: 'capacitor',
}, },
android: { android: {
allowMixedContent: false, allowMixedContent: false,
}, },
ios: {
// Prefer content-driven insets; CSS env(safe-area-inset-*) handles notch / home indicator.
contentInset: 'automatic',
// Keep WebView scroll on; rubber-band bounce disabled in AppDelegate after load.
scrollEnabled: true,
preferredContentMode: 'mobile',
},
plugins: { plugins: {
SplashScreen: { SplashScreen: {
launchAutoHide: true, launchAutoHide: true,
+20
View File
@@ -0,0 +1,20 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
# Signing / export artifacts — never commit
*.mobileprovision
*.p12
*.cer
ExportOptions.plist
*.ipa
@@ -0,0 +1,408 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:App.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+64
View File
@@ -0,0 +1,64 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Disable WKWebView rubber-band overscroll so the MUI chat layout feels native.
NotificationCenter.default.addObserver(
self,
selector: #selector(disableWebViewBounce),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
return true
}
@objc private func disableWebViewBounce() {
guard let root = window?.rootViewController else { return }
applyNoBounce(to: root)
}
private func applyNoBounce(to controller: UIViewController) {
if let bridge = controller as? CAPBridgeViewController,
let scrollView = bridge.webView?.scrollView {
scrollView.bounces = false
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
}
for child in controller.children {
applyNoBounce(to: child)
}
if let presented = controller.presentedViewController {
applyNoBounce(to: presented)
}
}
func applicationWillResignActive(_ application: UIApplication) {
}
func applicationDidEnterBackground(_ application: UIApplication) {
}
func applicationWillEnterForeground(_ application: UIApplication) {
}
func applicationDidBecomeActive(_ application: UIApplication) {
disableWebViewBounce()
}
func applicationWillTerminate(_ application: UIApplication) {
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,56 @@
{
"images": [
{
"idiom": "universal",
"filename": "Default@1x~universal~anyany.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "Default@2x~universal~anyany.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "Default@3x~universal~anyany.png",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "1x",
"filename": "Default@1x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "2x",
"filename": "Default@2x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "3x",
"filename": "Default@3x~universal~anyany-dark.png"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Hesychia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
+27
View File
@@ -0,0 +1,27 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end
target 'App' do
capacitor_pods
# Add your Pods here
end
post_install do |installer|
assertDeploymentTarget(installer)
end
+46
View File
@@ -0,0 +1,46 @@
PODS:
- Capacitor (7.6.8):
- CapacitorCordova
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorCordova (7.6.8)
- CapacitorKeyboard (7.0.6):
- Capacitor
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorStatusBar (7.0.6):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorKeyboard:
:path: "../../node_modules/@capacitor/keyboard"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
SPEC CHECKSUMS:
Capacitor: ff6bf01336ac353098378828aff465095a89e459
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCordova: e61ee8c40101b8cd011d0224261606a290c082cf
CapacitorKeyboard: a2e0869edd229490ce36aed2549c0d6b95e27ee8
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
PODFILE CHECKSUM: c2e7da979a3cb960e70090cd1956727cb3ca3413
COCOAPODS: 1.16.2
+89
View File
@@ -0,0 +1,89 @@
# Hesychia — iOS
Native Capacitor project for `ai.hesychia.chat`. Web UI comes from the CRA
`build/` output in the parent `llm-fe/` package (synced with `npx cap sync`).
Broader Capacitor notes (env files, icons, QA, TestFlight): [`../IOS.md`](../IOS.md).
## Prerequisites
- macOS + **Xcode** 15+
- Node.js **20**
- **CocoaPods** (`pod --version`)
- Open **`App.xcworkspace`** (not `.xcodeproj`)
## Build the web bundle and sync
From the **npm root** (`llm-fe/`, one level up):
```bash
cd ..
npm ci
npm run build:mobile # .env.mobile → react-scripts build → cap sync
```
Open in Xcode:
```bash
npm run ios:open
# or: npx cap open ios
```
Re-sync after changing web code (with an existing `build/`):
```bash
npm run ios:sync
```
Backend URLs are baked in at web-build time via `../.env.mobile` (prod by default).
After cloning, if Pods are missing:
```bash
cd App
pod install
```
## Run on a simulator / device
1. Open `App/App.xcworkspace` in Xcode.
2. Pick a simulator or a paired iPhone (Signing & Capabilities → your Team).
3. Product → Run (⌘R).
CLI (optional):
```bash
xcodebuild -workspace App/App.xcworkspace \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build
```
## Version before a store release
Edit target **App** → General, or `App.xcodeproj` build settings:
| Field | Build setting | Rule |
|-------|---------------|------|
| Version | `MARKETING_VERSION` | Keep in sync with `../package.json` `version` + Android `versionName` |
| Build | `CURRENT_PROJECT_VERSION` | Integer; **must increase** for every TestFlight / App Store upload |
Currently: version **0.1.0**, build **1**.
## Signing
**Do not commit** `.p12`, `.mobileprovision`, or export option files with secrets.
Debug / simulator: Automatic signing with a personal team is enough.
Distribution: App Store Connect + distribution certificate + profile — credentials
live outside this repo. CI/TestFlight automation is a follow-on ticket.
## Useful paths
| Path | Purpose |
|------|---------|
| `App/App/AppDelegate.swift` | Disables WKWebView rubber-band bounce |
| `App/App/Info.plist` | Display name **Hesychia**, version keys |
| `App/App/Assets.xcassets` | App icon + splash (regenerate via `npm run assets:generate`) |
| `App/Podfile` / `Podfile.lock` | CocoaPods (commit lockfile; Pods/ is gitignored) |
+1956 -1896
View File
File diff suppressed because it is too large Load Diff
+34 -5
View File
@@ -7,6 +7,7 @@
"@capacitor/app": "^7.1.2", "@capacitor/app": "^7.1.2",
"@capacitor/cli": "^7.6.8", "@capacitor/cli": "^7.6.8",
"@capacitor/core": "^7.6.8", "@capacitor/core": "^7.6.8",
"@capacitor/ios": "^7.6.8",
"@capacitor/keyboard": "^7.0.6", "@capacitor/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4", "@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6", "@capacitor/status-bar": "^7.0.6",
@@ -15,32 +16,39 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^5.16.11", "@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11", "@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"axios": "^1.7.9", "@types/papaparse": "^5.5.2",
"@types/pdfmake": "^0.3.3",
"axios": "^1.13.2",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"brace-expansion": "file:vendor/brace-expansion-compat",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"formik": "^2.4.6", "formik": "^2.4.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2", "markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1", "mini.css": "^3.0.1",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6", "react-bootstrap": "^2.10.6",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-github-btn": "^1.4.0", "react-github-btn": "^1.4.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.30.2",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.1", "recharts": "^2.15.1",
"styled-components": "^6.1.14", "styled-components": "6.1.15",
"stylis-plugin-rtl": "^2.1.1", "stylis-plugin-rtl": "^2.1.1",
"web-vitals": "^4.2.4", "web-vitals": "^4.2.4",
"webpack": "^5.97.1", "webpack": "^5.97.1",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"xlsx": "^0.18.5",
"yup": "^1.5.0" "yup": "^1.5.0"
}, },
"scripts": { "scripts": {
@@ -48,10 +56,12 @@
"build": "NODE_ENV=production react-scripts build", "build": "NODE_ENV=production react-scripts build",
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/", "build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/", "build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android", "build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync",
"android:open": "npx cap open android", "android:open": "npx cap open android",
"android:sync": "npx cap sync android", "android:sync": "npx cap sync android",
"assets:generate": "npx capacitor-assets generate --android", "ios:open": "npx cap open ios",
"ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test", "test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false", "test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject" "eject": "react-scripts eject"
@@ -76,6 +86,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@testing-library/dom": "^10.4.1",
"@types/bootstrap": "~5.2.10", "@types/bootstrap": "~5.2.10",
"@types/lodash": "~4.17.13", "@types/lodash": "~4.17.13",
"@types/react": "^18.3.16", "@types/react": "^18.3.16",
@@ -86,5 +97,23 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0", "react-google-recaptcha": "^3.1.0",
"typescript": "^4.9.5" "typescript": "^4.9.5"
},
"overrides": {
"tar": "7.5.22",
"underscore": "1.13.8",
"nth-check": "2.1.1",
"postcss": "8.5.25",
"serialize-javascript": "7.0.7",
"svgo": "2.8.3",
"sharp": "0.35.3",
"brace-expansion": "file:vendor/brace-expansion-compat",
"minimatch": "3.1.5",
"uuid": "11.1.1",
"prismjs": "1.30.0",
"bfj": "9.1.3",
"form-data": "4.0.6",
"ws": "8.21.1",
"flatted": "3.4.3",
"@tootallnate/once": "3.0.1"
} }
} }
+8
View File
@@ -11,6 +11,7 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext'; import { AuthContext } from './llm-fe/contexts/AuthContext';
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService'; import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage'; import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
import SetPassword from './llm-fe/components/SetPassword/SetPassword'; import SetPassword from './llm-fe/components/SetPassword/SetPassword';
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2'; import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2'; import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
@@ -23,6 +24,8 @@ import Tracker from './llm-fe/components/Tracker/Tracker';
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext'; import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession'; import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
const ProtectedRoutes = () => { const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext); const { authenticated, loading } = useContext(AuthContext);
@@ -47,12 +50,14 @@ class App extends Component {
<AnalyticsConsentProvider> <AnalyticsConsentProvider>
<Tracker /> <Tracker />
<AnalyticsSession /> <AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner /> <AnalyticsConsentBanner />
<div className='site'> <div className='site'>
<main> <main>
<div className="main-container"> <div className="main-container">
<AppErrorBoundary>
<Routes> <Routes>
<Route path='*' element={<NotFound />} /> <Route path='*' element={<NotFound />} />
@@ -67,6 +72,8 @@ class App extends Component {
{/* Public legal pages (also used for post-login ToS acknowledge) */} {/* Public legal pages (also used for post-login ToS acknowledge) */}
<Route path={"/terms_of_service/"} Component={TermsOfService} /> <Route path={"/terms_of_service/"} Component={TermsOfService} />
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} /> <Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route element={<ProtectedRoutes />}> <Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} /> <Route path={"/"} index={true} Component={AsyncDashboard2} />
@@ -78,6 +85,7 @@ class App extends Component {
</Route> </Route>
</Routes> </Routes>
</AppErrorBoundary>
</div> </div>
+2 -2
View File
@@ -6,7 +6,7 @@
/** /**
* Absolute backend OAuth start URL. * Absolute backend OAuth start URL.
* @param {'google'|'microsoft'} provider * @param {'google'|'microsoft'} provider
* @param {'login'|'signup'} intent * @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
* @returns {string} * @returns {string}
*/ */
export function oauthStartUrl(provider, intent = 'login') { export function oauthStartUrl(provider, intent = 'login') {
@@ -18,7 +18,7 @@ export function oauthStartUrl(provider, intent = 'login') {
/** /**
* Begin browser redirect to IdP via backend. * Begin browser redirect to IdP via backend.
* @param {'google'|'microsoft'} provider * @param {'google'|'microsoft'} provider
* @param {'login'|'signup'} intent * @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent
*/ */
export function startOAuth(provider, intent = 'login') { export function startOAuth(provider, intent = 'login') {
window.location.assign(oauthStartUrl(provider, intent)); window.location.assign(oauthStartUrl(provider, intent));
@@ -0,0 +1,134 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ActivityIndicator from './ActivityIndicator';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderIndicator = (
props: React.ComponentProps<typeof ActivityIndicator>,
) =>
render(
<ThemeProvider theme={theme as never}>
<ActivityIndicator {...props} />
</ThemeProvider>,
);
describe('ActivityIndicator (#96)', () => {
it('has status role and polite live region regardless of state', () => {
renderIndicator({ stage: null });
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('falls back to three dots when stage is null', () => {
renderIndicator({ stage: null });
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
it('renders the label verbatim for unknown/future stage values', () => {
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
expect(screen.getByText('Doing something new')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('shows optional detail text', () => {
renderIndicator({
stage: 'searching',
label: 'Searching the web',
detail: 'query: best pizza in town',
});
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
});
it('renders completed stages above with check marks', () => {
const history: ActivityHistoryEntry[] = [
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
];
renderIndicator({
stage: 'writing',
label: null,
history,
});
expect(screen.getByText('Searched the web')).toBeInTheDocument();
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
expect(screen.getAllByText('✓')).toHaveLength(2);
});
it('hides everything when interrupted', () => {
const { container } = renderIndicator({
stage: 'searching',
label: 'Searching the web',
interrupted: true,
});
expect(container).toBeEmptyDOMElement();
});
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
renderIndicator({ stage: 'writing', label: null });
expect(screen.getByText('Working…')).toBeInTheDocument();
});
describe('prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
});
it('disables the spinner animation when the user prefers reduced motion', () => {
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMediaMock,
});
renderIndicator({ stage: 'searching', label: 'Searching the web' });
const spinner = screen.getByTestId('activity-spinner');
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
});
});
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
jest.useFakeTimers();
try {
renderIndicator({ stage: 'searching', label: 'Searching the web' });
act(() => {
jest.advanceTimersByTime(11_000);
});
expect(screen.getByText('11s')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(20_000);
});
expect(screen.getByText('Still working…')).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,346 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
const MIN_DISPLAY_MS = 400;
const ELAPSED_THRESHOLD_MS = 10_000;
const SILENCE_THRESHOLD_MS = 30_000;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mql) return undefined;
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}
// Safari < 14 fallback
mql.addListener(handler);
return () => mql.removeListener(handler);
}, []);
return reduced;
}
type QueueItem = { stage: string; label: string | null; detail: string | null };
type IndicatorState = {
current: QueueItem | null;
queue: QueueItem[];
lastIncomingStage: string | null;
};
type IndicatorAction =
| { type: 'INCOMING'; item: QueueItem | null }
| { type: 'ADVANCE' }
| { type: 'RESET' };
const initialIndicatorState: IndicatorState = {
current: null,
queue: [],
lastIncomingStage: null,
};
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
switch (action.type) {
case 'RESET':
return initialIndicatorState;
case 'INCOMING': {
if (!action.item) return initialIndicatorState;
// Same stage as the last update seen — refresh content in place (label/detail tweak),
// no need to enforce the min-display queue since it isn't a stage transition.
if (action.item.stage === state.lastIncomingStage) {
if (state.queue.length > 0) {
const queue = [...state.queue];
queue[queue.length - 1] = action.item;
return { ...state, queue };
}
return { ...state, current: action.item };
}
if (!state.current) {
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
}
return {
...state,
queue: [...state.queue, action.item],
lastIncomingStage: action.item.stage,
};
}
case 'ADVANCE': {
if (state.queue.length === 0) return state;
const [next, ...rest] = state.queue;
return { ...state, current: next, queue: rest };
}
default:
return state;
}
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
const bounce = keyframes`
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
`;
const Root = styled.div`
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
width: 100%;
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
@media (max-width: 360px) {
font-size: 0.85rem;
}
`;
const HistoryList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
opacity: 0.6;
min-width: 0;
`;
const HistoryItem = styled.li`
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
`;
const CheckMark = styled.span`
flex-shrink: 0;
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
font-weight: 700;
`;
const CurrentRow = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
`;
const Spinner = styled.span<{ $reducedMotion: boolean }>`
flex-shrink: 0;
width: 14px;
height: 14px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.7;
${({ $reducedMotion }) =>
$reducedMotion
? css`
animation: none;
`
: css`
animation: ${spin} 0.8s linear infinite;
`}
`;
const TextColumn = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
`;
const LabelText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
`;
const DetailText = styled.span`
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
opacity: 0.65;
margin-top: 0.1rem;
`;
const MetaText = styled.span`
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.55;
white-space: nowrap;
`;
const DotsRow = styled.div`
display: flex;
align-items: center;
padding: 0.15rem 0;
`;
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: currentColor;
${({ $reducedMotion, $delay }) =>
$reducedMotion
? css`
opacity: 0.6;
`
: css`
animation: ${bounce} 1.4s infinite ease-in-out both;
animation-delay: ${$delay};
`}
`;
export type ActivityIndicatorProps = {
/** Current stage key from the latest "status" frame, or null before the first update. */
stage: string | null;
label?: string | null;
detail?: string | null;
/** Previously completed stages for this turn, oldest first. */
history?: ActivityHistoryEntry[];
/** Hides the indicator entirely once a stream is interrupted (#96). */
interrupted?: boolean;
};
const ActivityIndicator = ({
stage,
label = null,
detail = null,
history = [],
interrupted = false,
}: ActivityIndicatorProps): JSX.Element | null => {
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
const reducedMotion = usePrefersReducedMotion();
const overallStartRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
const currentSinceRef = useRef<number>(Date.now());
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceTick] = useState(0);
useEffect(() => {
if (interrupted || stage == null) {
overallStartRef.current = null;
dispatch({ type: 'RESET' });
return;
}
if (overallStartRef.current == null) {
overallStartRef.current = Date.now();
}
lastUpdateRef.current = Date.now();
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
}, [stage, label, detail, interrupted]);
// Reset the per-stage clock whenever the displayed stage changes.
useEffect(() => {
currentSinceRef.current = Date.now();
}, [state.current?.stage]);
// Enforce the minimum display time per stage before advancing the queue.
useEffect(() => {
if (advanceTimerRef.current) {
clearTimeout(advanceTimerRef.current);
advanceTimerRef.current = null;
}
if (state.queue.length === 0) return undefined;
const elapsed = Date.now() - currentSinceRef.current;
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
advanceTimerRef.current = setTimeout(() => {
dispatch({ type: 'ADVANCE' });
}, remaining);
return () => {
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
};
}, [state.queue.length]);
// Tick once a second while active so elapsed/"still working" copy stays fresh.
useEffect(() => {
if (!state.current) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(state.current)]);
if (interrupted) return null;
const now = Date.now();
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
const silentMs = now - lastUpdateRef.current;
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
return (
<Root role="status" aria-live="polite">
{history.length > 0 && (
<HistoryList>
{history.map((entry, i) => (
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
<CheckMark aria-hidden="true"></CheckMark>
<span>{entry.label}</span>
</HistoryItem>
))}
</HistoryList>
)}
{state.current ? (
<CurrentRow>
<Spinner
aria-hidden="true"
$reducedMotion={reducedMotion}
data-testid="activity-spinner"
data-reduced-motion={reducedMotion}
/>
<TextColumn>
<LabelText>
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
</LabelText>
{state.current.detail && (
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
)}
</TextColumn>
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
</CurrentRow>
) : (
<DotsRow data-testid="activity-dots-fallback">
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
<Dot $delay="0s" $reducedMotion={reducedMotion} />
</DotsRow>
)}
</Root>
);
};
export default ActivityIndicator;
@@ -0,0 +1,47 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import AppErrorBoundary from './AppErrorBoundary';
const Boom = (): JSX.Element => {
throw new Error('dashboard exploded');
};
describe('AppErrorBoundary', () => {
let consoleError: jest.SpyInstance;
beforeEach(() => {
consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleError.mockRestore();
});
it('renders children when nothing throws', () => {
render(
<MemoryRouter>
<AppErrorBoundary>
<div>chat loaded</div>
</AppErrorBoundary>
</MemoryRouter>,
);
expect(screen.getByText('chat loaded')).toBeInTheDocument();
});
it('shows the failure details instead of a blank screen', () => {
render(
<MemoryRouter>
<AppErrorBoundary>
<Boom />
</AppErrorBoundary>
</MemoryRouter>,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/Something broke on this screen/i)).toBeInTheDocument();
expect(screen.getByText(/dashboard exploded/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
type BoundaryProps = {
children: ReactNode;
};
type BoundaryState = {
error: Error | null;
componentStack: string | null;
};
/**
* Without a boundary any render/effect throw unmounts the whole tree, which reads
* as a blank white page on mobile with no way to see the cause on the device.
*/
class ErrorBoundaryView extends Component<BoundaryProps, BoundaryState> {
constructor(props: BoundaryProps) {
super(props);
this.state = { error: null, componentStack: null };
}
static getDerivedStateFromError(error: Error): Partial<BoundaryState> {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('App crashed', error, errorInfo.componentStack);
this.setState({ componentStack: errorInfo.componentStack ?? null });
}
private details(): string {
const { error, componentStack } = this.state;
return [
`Page: ${window.location.href}`,
`Agent: ${navigator.userAgent}`,
`Error: ${error?.name ?? 'Error'}: ${error?.message ?? 'unknown'}`,
error?.stack ? `Stack:\n${error.stack}` : '',
componentStack ? `Components:${componentStack}` : '',
]
.filter(Boolean)
.join('\n\n');
}
render(): ReactNode {
const { error } = this.state;
if (!error) {
return this.props.children;
}
return (
<div
role="alert"
style={{
minHeight: '100vh',
boxSizing: 'border-box',
padding: '1.5rem 1rem',
background: '#0f1220',
color: '#f5f6fa',
fontFamily: "'Inter', system-ui, sans-serif",
overflowWrap: 'anywhere',
}}
>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<h1 style={{ fontSize: '1.35rem', margin: '0 0 0.75rem' }}>Something broke on this screen</h1>
<p style={{ margin: '0 0 1rem', lineHeight: 1.5, color: 'rgba(245,246,250,0.75)' }}>
The rest of the app still works. Reload to try again, or copy the details below so the failure can be fixed.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1.25rem' }}>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '0.7rem 1.1rem',
borderRadius: '2rem',
border: 'none',
background: '#4c8dff',
color: '#fff',
fontWeight: 600,
cursor: 'pointer',
}}
>
Reload
</button>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(this.details());
}}
style={{
padding: '0.7rem 1.1rem',
borderRadius: '2rem',
border: '1px solid rgba(245,246,250,0.3)',
background: 'transparent',
color: '#f5f6fa',
fontWeight: 600,
cursor: 'pointer',
}}
>
Copy details
</button>
</div>
<pre
style={{
whiteSpace: 'pre-wrap',
fontSize: '0.8rem',
lineHeight: 1.5,
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '0.75rem',
padding: '1rem',
margin: 0,
maxHeight: '50vh',
overflow: 'auto',
}}
>
{this.details()}
</pre>
</div>
</div>
);
}
}
/** Route change clears a previous crash so navigation is never permanently stuck. */
const AppErrorBoundary = ({ children }: BoundaryProps): JSX.Element => {
const location = useLocation();
return <ErrorBoundaryView key={location.pathname}>{children}</ErrorBoundaryView>;
};
export default AppErrorBoundary;
@@ -0,0 +1,410 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from 'styled-components';
import BillingSection from './BillingSection';
const mockGet = jest.fn();
const mockPost = jest.fn();
const assignMock = jest.fn();
const mockTrackEvent = jest.fn();
const mockPurchasePlan = jest.fn();
const mockRestorePurchases = jest.fn();
const mockIsNativePlatform = jest.fn(() => false);
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}));
jest.mock('../../utils/analytics', () => ({
AnalyticsEvents: {
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
CHECKOUT_STARTED: 'Checkout Started',
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
PLAN_CHANGE_STARTED: 'Plan Change Started',
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
},
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}));
jest.mock('../../platform/nativePlatform', () => ({
isNativePlatform: () => mockIsNativePlatform(),
}));
jest.mock('../../utils/revenueCat', () => ({
purchasePlan: (...args: unknown[]) => mockPurchasePlan(...args),
restorePurchases: (...args: unknown[]) => mockRestorePurchases(...args),
isPurchaseCancelledError: (error: { userCancelled?: boolean }) =>
Boolean(error?.userCancelled),
}));
const theme = {
main: '#4a90e2',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.4)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const paidInvoice = {
id: 1,
provider: 'stripe',
status: 'paid',
currency: 'usd',
amount_due: 1000,
amount_paid: 1000,
period_start: '2026-07-01T00:00:00Z',
period_end: '2026-08-01T00:00:00Z',
stripe_invoice_id: 'in_test',
stripe_checkout_session_id: 'cs_test',
stripe_subscription_id: 'sub_test',
hosted_invoice_url: 'https://invoice.stripe.com/i/test',
description: 'Chat Subscription',
created: '2026-07-01T12:00:00Z',
last_modified: '2026-07-01T12:00:00Z',
};
const foundersPlan = {
slug: 'founders',
name: 'Founders',
description: '',
price_cents: 1000,
currency: 'usd',
interval: 'month',
is_public: true,
is_selectable: true,
features: {
text_generation: true,
image_generation: true,
all_future_features: true,
},
prompt_quota_per_window: 300,
prompt_window_hours: 6,
monthly_token_quota: null,
sort_order: 10,
};
const foundersSubscription = {
plan: foundersPlan,
status: 'active',
source: 'stripe',
needs_checkout: false,
stripe_subscription_id: 'sub_test',
cancel_at_period_end: false,
current_period_end: '2026-08-01T00:00:00Z',
usage: {
prompts_in_window: 2,
prompt_quota: 300,
prompts_remaining: 298,
window_hours: 6,
tokens_in_period: null,
tokens_out_period: null,
tokens_total_period: null,
turns_missing_token_usage: 0,
monthly_token_quota: null,
tokens_remaining: null,
period_start: null,
period_end: null,
},
};
const complimentarySubscription = {
...foundersSubscription,
source: 'backer',
stripe_subscription_id: '',
};
const emptySubscription = {
plan: null,
status: 'none',
source: 'none',
needs_checkout: true,
stripe_subscription_id: '',
cancel_at_period_end: false,
current_period_end: null,
usage: {
prompts_in_window: 0,
prompt_quota: null,
prompts_remaining: null,
window_hours: 6,
tokens_in_period: null,
tokens_out_period: null,
tokens_total_period: null,
turns_missing_token_usage: 0,
monthly_token_quota: null,
tokens_remaining: null,
period_start: null,
period_end: null,
},
};
type SubscriptionMock = typeof emptySubscription | typeof foundersSubscription;
const mockFinanceGets = ({
invoices = [] as unknown[],
payments = [] as unknown[],
subscription = emptySubscription as SubscriptionMock,
plans = [foundersPlan] as unknown[],
}: {
invoices?: unknown[];
payments?: unknown[];
subscription?: SubscriptionMock;
plans?: unknown[];
} = {}) => {
mockGet.mockImplementation((url: string) => {
if (url === '/monetization/invoices/') {
return Promise.resolve({ data: invoices });
}
if (url === '/monetization/payments/') {
return Promise.resolve({ data: payments });
}
if (url === '/monetization/subscription/') {
return Promise.resolve({ data: subscription });
}
if (url === '/monetization/plans/') {
return Promise.resolve({ data: plans });
}
return Promise.reject(new Error(`unexpected GET ${url}`));
});
};
const renderBilling = () =>
render(
<ThemeProvider theme={theme}>
<BillingSection />
</ThemeProvider>
);
describe('BillingSection', () => {
const originalLocation = window.location;
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
assignMock.mockReset();
mockTrackEvent.mockReset();
mockPurchasePlan.mockReset();
mockRestorePurchases.mockReset();
mockIsNativePlatform.mockReturnValue(false);
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
assign: assignMock,
origin: 'http://localhost',
},
});
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('renders plan summary and paid subscription management CTAs', async () => {
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
renderBilling();
expect(await screen.findByRole('button', { name: /^Upgrade$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Change plan/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
expect(screen.getByText('Founders')).toBeInTheDocument();
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
expect(screen.getByText('View')).toHaveAttribute(
'href',
'https://invoice.stripe.com/i/test'
);
});
it('opens Stripe portal for change plan when no alternate public plans', async () => {
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
plans: [foundersPlan],
});
mockPost.mockResolvedValue({
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
});
const user = userEvent.setup();
renderBilling();
await screen.findByRole('button', { name: /Change plan/i });
await user.click(screen.getByRole('button', { name: /Change plan/i }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
return_url: 'http://localhost/account/',
});
});
expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', {
source: 'account_billing',
});
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened', {
intent: 'change',
});
expect(assignMock).toHaveBeenCalledWith(
'https://billing.stripe.com/p/session/test'
);
});
it('confirms cancel then opens portal', async () => {
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
mockPost.mockResolvedValue({
data: { portal_url: 'https://billing.stripe.com/p/session/cancel' },
});
const user = userEvent.setup();
renderBilling();
await screen.findByRole('button', { name: /^Cancel$/i });
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
expect(await screen.findByTestId('cancel-confirm-modal')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Continue to cancel/i }));
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('Subscription Cancel Started', {
source: 'portal',
});
});
expect(assignMock).toHaveBeenCalledWith(
'https://billing.stripe.com/p/session/cancel'
);
});
it('shows complimentary messaging without cancel', async () => {
mockFinanceGets({
invoices: [],
subscription: complimentarySubscription,
});
renderBilling();
expect(
await screen.findByTestId('complimentary-billing-message')
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Cancel$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Upgrade$/i })).not.toBeInTheDocument();
});
it('shows empty unpaid state and starts checkout', async () => {
mockFinanceGets();
mockPost.mockResolvedValue({
data: { checkout_url: 'https://checkout.stripe.com/c/pay/cs_test' },
});
const user = userEvent.setup();
renderBilling();
expect(
await screen.findByText(/No active subscription yet/i)
).toBeInTheDocument();
expect(screen.getByText(/No invoices or payments yet/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/monetization/checkout/',
expect.objectContaining({
success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'),
})
);
});
expect(assignMock).toHaveBeenCalledWith(
'https://checkout.stripe.com/c/pay/cs_test'
);
});
it('shows visible error when finance list fails', async () => {
mockGet.mockRejectedValue({
response: { data: { detail: 'Finance unavailable' } },
});
renderBilling();
expect(await screen.findAllByRole('alert')).not.toHaveLength(0);
expect(screen.getAllByText('Finance unavailable').length).toBeGreaterThan(0);
});
it('shows visible error when portal open fails', async () => {
mockFinanceGets({
invoices: [paidInvoice],
subscription: foundersSubscription,
});
mockPost.mockRejectedValue({
response: { data: { detail: 'No Stripe customer found' } },
});
const user = userEvent.setup();
renderBilling();
await screen.findByRole('button', { name: /Manage subscription/i });
await user.click(screen.getByRole('button', { name: /Manage subscription/i }));
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
expect(assignMock).not.toHaveBeenCalled();
});
it('on native uses RevenueCat purchase and shows Restore', async () => {
mockIsNativePlatform.mockReturnValue(true);
mockFinanceGets();
mockPurchasePlan.mockResolvedValue(undefined);
const user = userEvent.setup();
renderBilling();
expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
await waitFor(() => {
expect(mockPurchasePlan).toHaveBeenCalled();
});
expect(mockPost).not.toHaveBeenCalledWith(
'/monetization/checkout/',
expect.anything()
);
});
it('shows provider badge for RevenueCat invoices', async () => {
mockFinanceGets({
invoices: [
{
...paidInvoice,
provider: 'revenuecat',
stripe_subscription_id: null,
revenuecat_store: 'PLAY_STORE',
hosted_invoice_url: '',
description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE',
},
],
subscription: {
...foundersSubscription,
source: 'revenuecat',
stripe_subscription_id: '',
},
});
renderBilling();
expect(await screen.findByText('Play Store')).toBeInTheDocument();
expect(screen.getByText(/Store IAP/)).toBeInTheDocument();
});
});
@@ -0,0 +1,907 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import {
canOpenBillingPortal,
checkoutReturnUrls,
FinanceInvoice,
FinancePayment,
formatBillingDate,
formatMoneyCents,
formatTokenCount,
higherSelectablePlans,
humanizeStatus,
invoiceProviderLabel,
isComplimentarySubscription,
isStoreSubscription,
isStripeSubscription,
otherSelectablePlans,
pickPrimaryInvoice,
SubscriptionMe,
SubscriptionPlanInfo,
} from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import {
isPurchaseCancelledError,
purchasePlan,
restorePurchases,
} from '../../utils/revenueCat';
const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 1000px;
margin-bottom: 2rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
`;
const CardTitle = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
color: ${({ theme }) => theme.colors.text};
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
padding-bottom: 1rem;
`;
const SettingRow = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
&:last-child {
border-bottom: none;
}
`;
const SettingLabel = styled.span`
font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text};
font-weight: 500;
opacity: 0.75;
`;
const SettingValue = styled.span`
font-size: 1.1rem;
color: ${({ theme }) => theme.colors.text};
font-weight: 600;
text-align: right;
`;
const BodyText = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.75;
margin: 0 0 1rem 0;
line-height: 1.5;
`;
const NoticeText = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.85;
margin: 1rem 0 0 0;
line-height: 1.5;
font-size: 0.95rem;
`;
const ErrorText = styled.p`
color: #ff6b6b;
margin: 0.75rem 0 0 0;
font-size: 0.95rem;
`;
const ButtonRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
`;
const StyledButton = styled.button`
background: ${({ theme }) => theme.main};
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 0.8rem 1.5rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
`;
const SecondaryButton = styled(StyledButton)`
background: transparent;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
color: ${({ theme }) => theme.colors.text};
&:hover {
box-shadow: none;
border-color: ${({ theme }) => theme.main};
}
`;
const DangerButton = styled(SecondaryButton)`
border-color: #ff6b6b66;
color: #ff6b6b;
&:hover {
border-color: #ff6b6b;
}
`;
const PlanList = styled.ul`
list-style: none;
margin: 1rem 0 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
`;
const PlanOption = styled.li`
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.75rem;
`;
const PlanMeta = styled.div`
flex: 1;
min-width: 200px;
`;
const PlanName = styled.div`
font-weight: 600;
color: ${({ theme }) => theme.colors.text};
margin-bottom: 0.25rem;
`;
const PlanDesc = styled.div`
font-size: 0.9rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
line-height: 1.4;
`;
const ModalBackdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
`;
const ModalCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(12px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 1.75rem;
max-width: 480px;
width: 100%;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
`;
const ModalTitle = styled.h3`
margin: 0 0 0.75rem 0;
color: ${({ theme }) => theme.colors.text};
font-size: 1.35rem;
`;
const StyledTable = styled.table`
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
`;
const Th = styled.th`
text-align: left;
padding: 1rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
font-weight: 600;
`;
const Td = styled.td`
padding: 1rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
color: ${({ theme }) => theme.colors.text};
`;
const InvoiceLink = styled.a`
color: ${({ theme }) => theme.main};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`;
const ProviderBadge = styled.span`
display: inline-block;
font-size: 0.8rem;
font-weight: 600;
opacity: 0.85;
white-space: nowrap;
`;
function apiErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return axiosError.response?.data?.detail || axiosError.message || fallback;
}
function storeManageLabel(): string {
if (typeof window === 'undefined') return 'App Store / Play Store';
const Cap = window.Capacitor as { getPlatform?: () => string } | undefined;
const platform = Cap?.getPlatform?.();
if (platform === 'ios') return 'App Store';
if (platform === 'android') return 'Play Store';
return 'App Store / Play Store';
}
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
const BillingSection = (): JSX.Element => {
const native = isNativePlatform();
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
const [payments, setPayments] = useState<FinancePayment[]>([]);
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const [plans, setPlans] = useState<SubscriptionPlanInfo[]>([]);
const [loading, setLoading] = useState(true);
const [listError, setListError] = useState('');
const [actionError, setActionError] = useState('');
const [actionNotice, setActionNotice] = useState('');
const [portalLoading, setPortalLoading] = useState(false);
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
const [restoreLoading, setRestoreLoading] = useState(false);
const [showPlanPicker, setShowPlanPicker] = useState(false);
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
const loadBilling = useCallback(async () => {
setLoading(true);
setListError('');
try {
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
await Promise.all([
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
]);
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
setSubscription(subscriptionResponse.data || null);
setPlans(Array.isArray(plansResponse.data) ? plansResponse.data : []);
} catch (error: unknown) {
setInvoices([]);
setPayments([]);
setSubscription(null);
setPlans([]);
setListError(apiErrorMessage(error, 'Could not load billing information.'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadBilling();
}, [loadBilling]);
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
const complimentary = useMemo(
() => isComplimentarySubscription(subscription, hasPortalAccess),
[subscription, hasPortalAccess]
);
const storeSub = useMemo(
() => isStoreSubscription(subscription?.source),
[subscription?.source]
);
const stripeSub = useMemo(
() => isStripeSubscription(subscription?.source) || hasPortalAccess,
[subscription?.source, hasPortalAccess]
);
const showStripeManage = !complimentary && stripeSub && hasPortalAccess;
const showStoreManage = !complimentary && (storeSub || (native && !showStripeManage && Boolean(subscription?.plan) && !subscription?.needs_checkout));
const upgradePlans = useMemo(
() => higherSelectablePlans(plans, subscription?.plan),
[plans, subscription?.plan]
);
const changePlans = useMemo(
() => otherSelectablePlans(plans, subscription?.plan?.slug),
[plans, subscription?.plan?.slug]
);
const periodEndLabel = useMemo(() => {
const end =
subscription?.current_period_end || primaryInvoice?.period_end || null;
return formatBillingDate(end);
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
const storeLabel = useMemo(() => storeManageLabel(), []);
const historyRows = useMemo(() => {
if (invoices.length) {
return invoices.map((invoice) => ({
key: `invoice-${invoice.id}`,
date: invoice.created,
description: invoice.description || 'Invoice',
provider: invoiceProviderLabel(invoice),
amount: formatMoneyCents(
invoice.amount_paid || invoice.amount_due,
invoice.currency
),
status: humanizeStatus(invoice.status),
url: invoice.hosted_invoice_url || '',
}));
}
return payments.map((payment) => ({
key: `payment-${payment.id}`,
date: payment.paid_at || payment.created,
description: 'Payment',
provider:
(payment.provider || '').toLowerCase() === 'revenuecat'
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
: payment.provider || '—',
amount: formatMoneyCents(payment.amount, payment.currency),
status: humanizeStatus(payment.status),
url: '',
}));
}, [invoices, payments]);
const openPortal = async (intent: PortalIntent) => {
setActionError('');
setActionNotice('');
setPortalLoading(true);
try {
const returnUrl = `${window.location.origin}/account/`;
const response = await axiosInstance.post<{ portal_url: string }>(
'/monetization/portal/',
{ return_url: returnUrl }
);
const portalUrl = response.data?.portal_url;
if (!portalUrl) {
setActionError('Billing portal could not be opened. Try again.');
return;
}
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent });
window.location.assign(portalUrl);
} catch (error: unknown) {
setActionError(
apiErrorMessage(error, 'Could not open the billing portal. Try again.')
);
} finally {
setPortalLoading(false);
}
};
const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
setActionError('');
setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__');
try {
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
source,
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
setActionNotice('Purchase cancelled.');
return;
}
setActionError(
apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.')
);
} finally {
setCheckoutLoadingSlug(null);
}
};
const handleRestore = async () => {
setActionError('');
setActionNotice('');
setRestoreLoading(true);
try {
await restorePurchases();
setActionNotice('Purchases restored. Refreshing billing…');
await loadBilling();
} catch (error: unknown) {
setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.'));
} finally {
setRestoreLoading(false);
}
};
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
if (native) {
await handleNativePurchase(planSlug, source);
return;
}
setActionError('');
setActionNotice('');
setCheckoutLoadingSlug(planSlug || '__default__');
try {
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
source,
...(planSlug ? { plan_slug: planSlug } : {}),
});
const response = await axiosInstance.post<{ checkout_url: string }>(
'/monetization/checkout/',
{
success_url,
cancel_url,
...(planSlug ? { plan_slug: planSlug } : {}),
}
);
const checkoutUrl = response.data?.checkout_url;
if (!checkoutUrl) {
setActionError('Checkout could not be started. Try again.');
return;
}
window.location.assign(checkoutUrl);
} catch (error: unknown) {
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
} finally {
setCheckoutLoadingSlug(null);
}
};
const handleUpgradeClick = () => {
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
source: 'account_billing',
});
if (upgradePlans.length > 0) {
setPlanPickerMode('upgrade');
setShowPlanPicker(true);
return;
}
if (native && (storeSub || showStoreManage)) {
void handleNativePurchase(undefined, 'account_upgrade');
return;
}
void openPortal('upgrade');
};
const handleChangePlanClick = () => {
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { source: 'account_billing' });
if (changePlans.length > 0) {
setPlanPickerMode('change');
setShowPlanPicker(true);
return;
}
if (native && (storeSub || showStoreManage)) {
setActionNotice(
`Change or cancel your plan in ${storeLabel} subscription settings.`
);
return;
}
void openPortal('change');
};
const handleConfirmCancel = async () => {
if (native && (storeSub || showStoreManage) && !showStripeManage) {
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'store' });
setCancelConfirmOpen(false);
setActionNotice(
`Open ${storeLabel} → Subscriptions to cancel. Access usually continues until the period end.`
);
return;
}
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
setCancelConfirmOpen(false);
await openPortal('cancel');
};
const pickerPlans = planPickerMode === 'upgrade' ? upgradePlans : changePlans;
const selectPlanFromPicker = async (plan: SubscriptionPlanInfo) => {
if (planPickerMode === 'upgrade') {
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
source: 'plan_picker',
plan_slug: plan.slug,
});
await handleStartCheckout(plan.slug, 'account_upgrade');
return;
}
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, {
source: 'plan_picker',
plan_slug: plan.slug,
});
if (native && (storeSub || showStoreManage) && !showStripeManage) {
setShowPlanPicker(false);
await handleNativePurchase(plan.slug, 'account_change');
return;
}
// Existing Stripe subscribers change plans in the portal (proration / PCI).
setShowPlanPicker(false);
await openPortal('change');
};
return (
<>
<GlassCard data-testid="billing-section">
<CardTitle>Billing</CardTitle>
{loading ? (
<BodyText>Loading billing information</BodyText>
) : listError ? (
<ErrorText role="alert">{listError}</ErrorText>
) : subscription?.plan || primaryInvoice ? (
<>
<SettingRow>
<SettingLabel>Plan</SettingLabel>
<SettingValue>
{subscription?.plan?.name ||
primaryInvoice?.description ||
'Chat Subscription'}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Status</SettingLabel>
<SettingValue>
{humanizeStatus(subscription?.status || primaryInvoice?.status || 'none')}
</SettingValue>
</SettingRow>
{subscription?.plan ? (
<SettingRow>
<SettingLabel>Price</SettingLabel>
<SettingValue>
{formatMoneyCents(
subscription.plan.price_cents,
subscription.plan.currency
)}
{subscription.plan.interval ? ` / ${subscription.plan.interval}` : ''}
</SettingValue>
</SettingRow>
) : primaryInvoice ? (
<SettingRow>
<SettingLabel>Amount</SettingLabel>
<SettingValue>
{formatMoneyCents(
primaryInvoice.amount_paid || primaryInvoice.amount_due,
primaryInvoice.currency
)}
{primaryInvoice.period_end ? ' / period' : ''}
</SettingValue>
</SettingRow>
) : null}
{subscription?.usage ? (
<>
<SettingRow>
<SettingLabel>Prompts remaining</SettingLabel>
<SettingValue>
{subscription.usage.prompts_remaining === null
? '—'
: `${subscription.usage.prompts_remaining} / ${subscription.usage.prompt_quota} (${subscription.usage.window_hours}h)`}
</SettingValue>
</SettingRow>
<SettingRow>
<SettingLabel>Tokens this period</SettingLabel>
<SettingValue>
in {formatTokenCount(subscription.usage.tokens_in_period)} · out{' '}
{formatTokenCount(subscription.usage.tokens_out_period)}
</SettingValue>
</SettingRow>
</>
) : null}
<SettingRow>
<SettingLabel>Period end</SettingLabel>
<SettingValue>{periodEndLabel}</SettingValue>
</SettingRow>
{subscription?.cancel_at_period_end ? (
<NoticeText data-testid="cancel-scheduled-notice">
Cancellation scheduled. You keep access until {periodEndLabel}.
</NoticeText>
) : null}
{subscription?.status === 'canceled' ? (
<NoticeText>
Subscription canceled. Renew via Complete payment when you are ready.
</NoticeText>
) : null}
</>
) : (
<BodyText>
No active subscription yet. Complete payment to unlock billing management
and invoice history.
</BodyText>
)}
{!loading && !listError && (
<ButtonRow>
{showStripeManage ? (
<>
<StyledButton
type="button"
onClick={handleUpgradeClick}
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
>
Upgrade
</StyledButton>
<SecondaryButton
type="button"
onClick={handleChangePlanClick}
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
>
Change plan
</SecondaryButton>
{!subscription?.cancel_at_period_end &&
subscription?.status !== 'canceled' ? (
<DangerButton
type="button"
onClick={() => setCancelConfirmOpen(true)}
disabled={portalLoading}
>
Cancel
</DangerButton>
) : null}
<SecondaryButton
type="button"
onClick={() => openPortal('manage')}
disabled={portalLoading}
>
{portalLoading ? 'Opening…' : 'Manage subscription'}
</SecondaryButton>
</>
) : complimentary ? (
<BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
Complimentary access no payment required. Plan changes and
cancellation are not available for this account.
</BodyText>
) : showStoreManage ? (
<>
<StyledButton
type="button"
onClick={handleUpgradeClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Upgrade
</StyledButton>
<SecondaryButton
type="button"
onClick={handleChangePlanClick}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
Change plan
</SecondaryButton>
{!subscription?.cancel_at_period_end &&
subscription?.status !== 'canceled' ? (
<DangerButton
type="button"
onClick={() => setCancelConfirmOpen(true)}
disabled={Boolean(checkoutLoadingSlug)}
>
Cancel
</DangerButton>
) : null}
<SecondaryButton
type="button"
onClick={() =>
setActionNotice(
`Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.`
)
}
data-testid="store-manage-hint"
>
Manage in {storeLabel}
</SecondaryButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
) : (
<>
<StyledButton
type="button"
onClick={() => handleStartCheckout()}
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
>
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
</StyledButton>
{native ? (
<SecondaryButton
type="button"
onClick={handleRestore}
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
data-testid="restore-purchases"
>
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
</SecondaryButton>
) : null}
</>
)}
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
Refresh
</SecondaryButton>
</ButtonRow>
)}
{showPlanPicker && pickerPlans.length > 0 ? (
<div data-testid="plan-picker">
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
{planPickerMode === 'upgrade'
? native && !showStripeManage
? 'Choose a higher plan. Purchase completes in the app store.'
: 'Choose a higher plan. Checkout opens securely in Stripe.'
: native && !showStripeManage
? 'Select another plan to purchase via the app store.'
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
</BodyText>
<PlanList>
{pickerPlans.map((plan) => (
<PlanOption key={plan.slug}>
<PlanMeta>
<PlanName>{plan.name}</PlanName>
<PlanDesc>
{formatMoneyCents(plan.price_cents, plan.currency)}
{plan.interval ? ` / ${plan.interval}` : ''}
{plan.description ? `${plan.description}` : ''}
{` · ${plan.prompt_quota_per_window} prompts / ${plan.prompt_window_hours}h`}
</PlanDesc>
</PlanMeta>
<StyledButton
type="button"
onClick={() => selectPlanFromPicker(plan)}
disabled={
portalLoading ||
checkoutLoadingSlug === plan.slug ||
checkoutLoadingSlug === '__default__'
}
>
{checkoutLoadingSlug === plan.slug
? 'Starting…'
: planPickerMode === 'upgrade'
? 'Upgrade'
: 'Select'}
</StyledButton>
</PlanOption>
))}
</PlanList>
<ButtonRow>
<SecondaryButton type="button" onClick={() => setShowPlanPicker(false)}>
Close
</SecondaryButton>
</ButtonRow>
</div>
) : null}
{actionNotice ? (
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
) : null}
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
</GlassCard>
<GlassCard data-testid="billing-history">
<CardTitle>Billing history</CardTitle>
{loading ? (
<BodyText>Loading history</BodyText>
) : listError ? (
<ErrorText role="alert">{listError}</ErrorText>
) : historyRows.length === 0 ? (
<BodyText>No invoices or payments yet.</BodyText>
) : (
<div style={{ overflowX: 'auto' }}>
<StyledTable>
<thead>
<tr>
<Th>Date</Th>
<Th>Provider</Th>
<Th>Description</Th>
<Th>Amount</Th>
<Th>Status</Th>
<Th>Invoice</Th>
</tr>
</thead>
<tbody>
{historyRows.map((row) => (
<tr key={row.key}>
<Td>{formatBillingDate(row.date)}</Td>
<Td>
<ProviderBadge>{row.provider}</ProviderBadge>
</Td>
<Td>{row.description}</Td>
<Td>{row.amount}</Td>
<Td>{row.status}</Td>
<Td>
{row.url ? (
<InvoiceLink href={row.url} target="_blank" rel="noreferrer">
View
</InvoiceLink>
) : (
'—'
)}
</Td>
</tr>
))}
</tbody>
</StyledTable>
</div>
)}
</GlassCard>
{cancelConfirmOpen ? (
<ModalBackdrop
role="presentation"
onClick={() => setCancelConfirmOpen(false)}
data-testid="cancel-confirm-modal"
>
<ModalCard
role="dialog"
aria-modal="true"
aria-labelledby="cancel-subscription-title"
onClick={(event) => event.stopPropagation()}
>
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
<BodyText>
{native && (storeSub || showStoreManage) && !showStripeManage
? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`
: `You will finish canceling in the Stripe customer portal. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`}
</BodyText>
<ButtonRow>
<DangerButton
type="button"
onClick={handleConfirmCancel}
disabled={portalLoading}
>
{native && (storeSub || showStoreManage) && !showStripeManage
? 'Got it'
: portalLoading
? 'Opening…'
: 'Continue to cancel'}
</DangerButton>
<SecondaryButton
type="button"
onClick={() => setCancelConfirmOpen(false)}
disabled={portalLoading}
>
Keep subscription
</SecondaryButton>
</ButtonRow>
</ModalCard>
</ModalBackdrop>
) : null}
</>
);
};
export default BillingSection;
@@ -0,0 +1,74 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import ConversationDetailCard, {
injectCitationMarkers,
} from './ConversationDetailCard';
const theme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderCard = (props: React.ComponentProps<typeof ConversationDetailCard>) =>
render(
<MemoryRouter>
<ThemeProvider theme={theme as never}>
<ConversationDetailCard {...props} />
</ThemeProvider>
</MemoryRouter>,
);
describe('ConversationDetailCard citations (#98)', () => {
it('injects citation markers outside code fences', () => {
const md = 'See [1] and [2, 3]\n\n```\n[9]\n```';
const out = injectCitationMarkers(md);
expect(out).toContain('<citation indices="1"></citation>');
expect(out).toContain('<citation indices="2,3"></citation>');
expect(out).toContain('```\n[9]\n```');
});
it('does not render Sources list card', () => {
renderCard({
message: 'Answer with [1]',
user_created: false,
citations: [
{
index: 1,
title: 'Example Source',
url: 'https://example.com/a',
published_at: '2026-07-03',
},
],
});
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
});
it('opens citation URL in a new tab when inline marker clicked', async () => {
const user = userEvent.setup();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
renderCard({
message: 'See [1]',
user_created: false,
citations: [
{ index: 1, title: 'Src', url: 'https://example.com', published_at: null },
],
});
await user.click(screen.getByRole('button', { name: 'Source 1' }));
expect(openSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer',
);
openSpy.mockRestore();
});
});
@@ -0,0 +1,149 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import ConversationDetailCard from './ConversationDetailCard';
const darkTheme = {
main: '#336699',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.3)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const lightTheme = {
main: '#336699',
focus: '#224466',
darkMode: false,
colors: {
text: '#111111',
cardBackground: 'rgba(255,255,255,0.8)',
cardBorder: 'rgba(0,0,0,0.1)',
},
};
const renderCard = (
props: {
message: string;
user_created: boolean;
},
theme: typeof darkTheme = darkTheme
) =>
render(
<MemoryRouter>
<ThemeProvider theme={theme as never}>
<ConversationDetailCard {...props} />
</ThemeProvider>
</MemoryRouter>
);
describe('ConversationDetailCard', () => {
it('renders assistant message without token meta', () => {
renderCard({
message: 'Hello from the model',
user_created: false,
});
expect(screen.getByText('Hello from the model')).toBeInTheDocument();
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
expect(screen.queryByText(/Tokens in/i)).not.toBeInTheDocument();
});
it('renders user message without token meta', () => {
renderCard({
message: 'Hi',
user_created: true,
});
expect(screen.getByText('Hi')).toBeInTheDocument();
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
});
it('shows a friendly upgrade bubble for RAG feature_not_allowed errors (#85)', () => {
renderCard({
message: JSON.stringify({
type: 'error',
code: 'feature_not_allowed',
content: 'Your plan does not include rag document search.',
details: { feature: 'rag' },
}),
user_created: false,
});
expect(
screen.getByText('Your plan does not include rag document search.')
).toBeInTheDocument();
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeLink).toBeInTheDocument();
expect(upgradeLink).toHaveAttribute('href', '/account/');
});
it('renders unrelated feature_not_allowed errors as a normal inline error, not the upgrade bubble', () => {
renderCard({
message: JSON.stringify({
type: 'error',
code: 'feature_not_allowed',
content: 'Your plan does not include image generation.',
details: { feature: 'image_generation' },
}),
user_created: false,
});
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
expect(screen.getByText(/image generation/i)).toBeInTheDocument();
});
it('uses theme text color for agent bubble in light mode (#88)', () => {
renderCard(
{ message: 'Light mode reply', user_created: false },
lightTheme
);
const message = screen.getByText('Light mode reply');
const agentBubble = message.closest('div');
expect(agentBubble).not.toBeNull();
expect(getComputedStyle(agentBubble!).color).toBe('rgb(17, 17, 17)');
});
it('keeps light agent text in dark mode (#88)', () => {
renderCard(
{ message: 'Dark mode reply', user_created: false },
darkTheme
);
const message = screen.getByText('Dark mode reply');
const agentBubble = message.closest('div');
expect(agentBubble).not.toBeNull();
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
});
it('renders activity label instead of dots when stage is set (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard
message=""
user_created={false}
activityStage="searching"
activityLabel="Searching the web"
activityDetail="Taylor Swift wedding"
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByText('Searching the web')).toBeInTheDocument();
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
});
it('falls back to dots when empty message has no activity stage (#96)', () => {
render(
<MemoryRouter>
<ThemeProvider theme={darkTheme as never}>
<ConversationDetailCard message="" user_created={false} />
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
});
});
@@ -1,6 +1,13 @@
import React from "react"; import React, { useCallback, useMemo } from 'react';
import Markdown from "markdown-to-jsx"; import Markdown from 'markdown-to-jsx';
import styled, { keyframes } from "styled-components"; import { Link } from 'react-router-dom';
import styled, { keyframes } from 'styled-components';
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
import type { PromptRating } from '../../utils/promptFeedback';
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
import MessageActions from '../MessageActions/MessageActions';
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
const fadeIn = keyframes` const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
@@ -9,36 +16,60 @@ const fadeIn = keyframes`
const MessageContainer = styled.div<{ $isUser: boolean }>` const MessageContainer = styled.div<{ $isUser: boolean }>`
display: flex; display: flex;
justify-content: ${(props) => (props.$isUser ? "flex-end" : "flex-start")}; flex-direction: column;
align-items: ${(props) => (props.$isUser ? 'flex-end' : 'flex-start')};
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
width: 100%; width: 100%;
animation: ${fadeIn} 0.3s ease-out; animation: ${fadeIn} 0.3s ease-out;
&:hover [data-message-actions],
&:focus-within [data-message-actions] {
opacity: 1 !important;
pointer-events: auto !important;
}
`; `;
const Bubble = styled.div<{ $isUser: boolean }>` const Bubble = styled.div<{ $isUser: boolean }>`
max-width: 80%; max-width: 80%;
min-width: 0;
overflow-wrap: anywhere;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-radius: 1.2rem; border-radius: 1.2rem;
background: ${(props) => background: ${(props) =>
props.$isUser props.$isUser
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)` ? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
: props.theme.darkMode : props.theme.darkMode
? "rgba(255, 255, 255, 0.1)" ? 'rgba(255, 255, 255, 0.1)'
: "rgba(0, 0, 0, 0.7)"}; // Dark background for AI in light mode as requested : 'rgba(0, 0, 0, 0.06)'};
color: #fff; // Text stays white as background is always dark/colored color: ${(props) =>
props.$isUser || props.theme.darkMode ? '#fff' : props.theme.colors.text};
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid ${(props) => props.theme.darkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"}; border: 1px solid ${(props) =>
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); props.$isUser
? props.theme.darkMode
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'
: props.theme.darkMode
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.08)'};
box-shadow: ${(props) =>
props.$isUser || props.theme.darkMode
? '0 4px 15px rgba(0, 0, 0, 0.2)'
: '0 2px 10px rgba(0, 0, 0, 0.08)'};
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")}; border-bottom-right-radius: ${(props) => (props.$isUser ? '0.2rem' : '1.2rem')};
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")}; border-bottom-left-radius: ${(props) => (props.$isUser ? '1.2rem' : '0.2rem')};
& pre { & pre {
background: rgba(0, 0, 0, 0.3); background: ${(props) =>
props.$isUser || props.theme.darkMode
? 'rgba(0, 0, 0, 0.3)'
: 'rgba(0, 0, 0, 0.06)'};
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow-x: auto; overflow-x: auto;
max-width: 100%;
} }
& code { & code {
@@ -47,56 +78,114 @@ const Bubble = styled.div<{ $isUser: boolean }>`
} }
& a { & a {
color: #a0c4ff; color: ${(props) =>
props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main};
text-decoration: underline; text-decoration: underline;
} }
`;
const LoadingDot = styled.div` & img,
width: 8px; & table {
height: 8px; max-width: 100%;
background: #fff; }
border-radius: 50%;
margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; } @media (max-width: 768px) {
&:nth-child(2) { animation-delay: -0.16s; } max-width: 92%;
padding: 0.75rem 1rem;
@keyframes bounce { font-size: 0.95rem;
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
} }
`; `;
const LoadingContainer = styled.div` const UpgradeNotice = styled.div`
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; align-items: flex-start;
padding: 0.5rem; gap: 0.65rem;
`; `;
const UpgradeLink = styled(Link)`
background: ${(props) => props.theme.main};
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 0.85rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
&:hover {
opacity: 0.9;
}
`;
const CitationButton = styled.button`
display: inline;
margin: 0 0.1rem;
padding: 0 0.25rem;
border: none;
border-radius: 0.25rem;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(160, 196, 255, 0.2)' : 'rgba(51, 102, 153, 0.12)'};
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
font: inherit;
font-size: 0.85em;
font-weight: 600;
cursor: pointer;
vertical-align: baseline;
&:hover,
&:focus-visible {
outline: 2px solid ${({ theme }) => theme.main};
outline-offset: 1px;
}
`;
/** Linkify [n] / [1, 2] outside fenced code blocks. */
export function injectCitationMarkers(markdown: string): string {
const parts = markdown.split(/(```[\s\S]*?```)/g);
return parts
.map((part) => {
if (part.startsWith('```')) return part;
return part.replace(
/\[(\d+(?:\s*,\s*\d+)*)\]/g,
(_match, nums: string) =>
`<citation indices="${nums.replace(/\s+/g, '')}"></citation>`,
);
})
.join('');
}
type ConversationDetailCardProps = { type ConversationDetailCardProps = {
message: string; message: string;
user_created: boolean; user_created: boolean;
promptId?: number;
citations?: Citation[];
isStreaming?: boolean;
isLast?: boolean;
conversationTitle?: string;
createdTimestamp?: Date | string | null;
initialRating?: PromptRating | null;
onRatingChange?: (rating: PromptRating | null) => void;
/** Live activity status for the empty-message (streaming placeholder) bubble (#96). */
activityStage?: string | null;
activityLabel?: string | null;
activityDetail?: string | null;
activityHistory?: ActivityHistoryEntry[];
activityInterrupted?: boolean;
}; };
// Custom component for rendering plots
const MyPlot = ({ format, image }: { format: string; image: string }) => { const MyPlot = ({ format, image }: { format: string; image: string }) => {
const imageSrc = `data:image/${format};base64,${image}`; const imageSrc = `data:image/${format};base64,${image}`;
return ( return (
<img <img
src={imageSrc} src={imageSrc}
style={{ maxWidth: "100%", height: "auto", borderRadius: "8px", marginTop: "10px" }} style={{ maxWidth: '100%', height: 'auto', borderRadius: '8px', marginTop: '10px' }}
alt="plot" alt="plot"
/> />
); );
}; };
// Custom component for rendering errors
const MyError = ({ content }: { content: string }) => { const MyError = ({ content }: { content: string }) => {
return ( return (
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}> <span style={{ color: '#ff6b6b', fontWeight: 'bold', display: 'block', marginTop: '0.5rem' }}>
Error: {content} Error: {content}
</span> </span>
); );
@@ -105,51 +194,131 @@ const MyError = ({ content }: { content: string }) => {
const ConversationDetailCard = ({ const ConversationDetailCard = ({
message, message,
user_created, user_created,
promptId,
citations = [],
isStreaming = false,
isLast = false,
conversationTitle = 'conversation',
createdTimestamp = null,
initialRating = null,
onRatingChange,
activityStage = null,
activityLabel = null,
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
}: ConversationDetailCardProps): JSX.Element => { }: ConversationDetailCardProps): JSX.Element => {
const CitationMark = useCallback(
({ indices }: { indices?: string }) => {
const list = (indices || '')
.split(',')
.map((n) => Number(n.trim()))
.filter((n) => !Number.isNaN(n));
if (!list.length) return null;
return (
<>
{list.map((index, i) => {
const citation = citations.find((c) => c.index === index);
return (
<CitationButton
key={`${index}-${i}`}
type="button"
aria-label={`Source ${index}`}
title={citation?.title || `Source ${index}`}
onClick={() => {
if (citation?.url) {
window.open(citation.url, '_blank', 'noopener,noreferrer');
}
}}
>
[{index}]
</CitationButton>
);
})}
</>
);
},
[citations],
);
const displayMarkdown = useMemo(() => {
if (!message) return message;
if (!citations.length || user_created) return message;
return injectCitationMarkers(message);
}, [message, citations, user_created]);
if (message.length === 0) { if (message.length === 0) {
return ( return (
<MessageContainer $isUser={false}> <MessageContainer $isUser={false}>
<Bubble $isUser={false}> <Bubble $isUser={false}>
<LoadingContainer> <ActivityIndicator
<LoadingDot /> stage={activityStage}
<LoadingDot /> label={activityLabel}
<LoadingDot /> detail={activityDetail}
</LoadingContainer> history={activityHistory}
interrupted={activityInterrupted}
/>
</Bubble> </Bubble>
</MessageContainer> </MessageContainer>
); );
} }
let contentToAdd = message; const errorPayload = parseChatErrorPayload(message);
if (errorPayload && isRagFeatureNotAllowed(errorPayload)) {
return (
<MessageContainer $isUser={false}>
<Bubble $isUser={false}>
<UpgradeNotice>
<span>
{errorPayload.content ||
"Document search (RAG) isn't included in your current plan."}
</span>
<UpgradeLink to="/account/">Upgrade your plan</UpgradeLink>
</UpgradeNotice>
</Bubble>
</MessageContainer>
);
}
let contentToAdd = displayMarkdown;
let rawForCopy = message;
try { try {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
if ( if (
parsedMessage && parsedMessage &&
typeof parsedMessage === "object" && typeof parsedMessage === 'object' &&
parsedMessage.type parsedMessage.type
) { ) {
switch (parsedMessage.type) { switch (parsedMessage.type) {
case "text": case 'text':
contentToAdd = parsedMessage.content; rawForCopy = parsedMessage.content;
contentToAdd =
citations.length && !user_created
? injectCitationMarkers(parsedMessage.content)
: parsedMessage.content;
break; break;
case "plot": case 'plot':
contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`; contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`;
rawForCopy = message;
break; break;
case "error": case 'error':
contentToAdd = `<error content="${parsedMessage.content}"></error>`; contentToAdd = `<error content="${parsedMessage.content}"></error>`;
rawForCopy = message;
break; break;
default: default:
break; break;
} }
} }
} catch { } } catch {
/* plain markdown */
}
return ( return (
<MessageContainer $isUser={user_created}> <MessageContainer $isUser={user_created}>
<Bubble $isUser={user_created}> <Bubble $isUser={user_created}>
<Markdown <Markdown
className="display-linebreak" className="display-linebreak"
style={{ whiteSpace: "pre-line" }} style={{ whiteSpace: 'pre-line' }}
options={{ options={{
overrides: { overrides: {
plot: { plot: {
@@ -158,12 +327,31 @@ const ConversationDetailCard = ({
error: { error: {
component: MyError, component: MyError,
}, },
pre: {
component: CustomPreBlock,
},
citation: {
component: CitationMark,
},
}, },
}} }}
> >
{contentToAdd} {contentToAdd}
</Markdown> </Markdown>
</Bubble> </Bubble>
<MessageActions
promptId={promptId}
rawMarkdown={rawForCopy}
userCreated={user_created}
isStreaming={isStreaming}
isLast={isLast}
conversationTitle={conversationTitle}
createdTimestamp={createdTimestamp}
citations={citations}
initialRating={initialRating}
onRatingChange={onRatingChange}
/>
</MessageContainer> </MessageContainer>
); );
}; };
@@ -1,25 +1,72 @@
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; import React, { useState } from 'react';
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx"; import styled from 'styled-components';
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
import tsx from 'react-syntax-highlighter/dist/cjs/languages/prism/tsx';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import ContentCopy from '@mui/icons-material/ContentCopy';
import Check from '@mui/icons-material/Check';
import { IconButton, Tooltip } from '@mui/material';
import { copyTextToClipboard } from '../../utils/clipboard';
import { showToast } from '../../utils/toastBus';
SyntaxHighlighter.registerLanguage("tsx", tsx); SyntaxHighlighter.registerLanguage('tsx', tsx);
type CustomCodeBlock = { const Wrapper = styled.div`
children: string, position: relative;
className: string max-width: 100%;
`;
const CopyBtn = styled(IconButton)`
&& {
position: absolute;
top: 0.35rem;
right: 0.35rem;
z-index: 1;
color: #fff;
background: rgba(0, 0, 0, 0.35);
padding: 0.25rem;
&:hover {
background: rgba(0, 0, 0, 0.55);
} }
}
`;
type CustomCodeBlockProps = {
children: string;
className?: string;
};
const CustomCodeBlock = ({
children,
className,
}: CustomCodeBlockProps): JSX.Element => {
const [copied, setCopied] = useState(false);
const language = className?.replace('lang-', '').replace('language-', '') || undefined;
const code = typeof children === 'string' ? children : String(children ?? '');
const handleCopy = async () => {
try {
await copyTextToClipboard(code.replace(/\n$/, ''));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
showToast('Could not copy code', 'error');
}
};
const CustomCodeBlock = ({children, className}: CustomCodeBlock): JSX.Element => {
const language = className?.replace("lang-","");
return ( return (
<Wrapper>
<Tooltip title={copied ? 'Copied' : 'Copy code'}>
<CopyBtn aria-label="Copy code block" size="small" onClick={() => void handleCopy()}>
{copied ? <Check fontSize="inherit" /> : <ContentCopy fontSize="inherit" />}
</CopyBtn>
</Tooltip>
<SyntaxHighlighter language={language} style={oneDark}> <SyntaxHighlighter language={language} style={oneDark}>
{children} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
) </Wrapper>
} );
};
export default CustomCodeBlock; export default CustomCodeBlock;
@@ -1,14 +1,22 @@
import CustomCodeBlock from "../CustomCodeBlock/CustomCodeBlock" import CustomCodeBlock from '../CustomCodeBlock/CustomCodeBlock';
type CustomPreBlockProps = { type CustomPreBlockProps = {
children: JSX.Element | JSX.Element[] children: JSX.Element | JSX.Element[];
} };
const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => { const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => {
if ("type" in children && children["type"] === "code") { const child = Array.isArray(children) ? children[0] : children;
return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] }); if (child && typeof child === 'object' && 'type' in child && child.type === 'code') {
return (
<CustomCodeBlock
className={(child.props as { className?: string }).className}
>
{(child.props as { children?: string }).children as string}
</CustomCodeBlock>
);
} }
return <pre {...rest}>{children}</pre> return <pre {...rest}>{children}</pre>;
} };
export default CustomPreBlock; export default CustomPreBlock;
@@ -39,6 +39,8 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
const [onMouseEnter, setOnMouseEnter] = useState(false); const [onMouseEnter, setOnMouseEnter] = useState(false);
const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null); const [rtlCache, setRtlCache] = useState<EmotionCache | null>(null);
const { pathname } = useLocation(); const { pathname } = useLocation();
const brandName =
process.env.REACT_APP_DEPLOY_ENV === "beta" ? "Beta Hesychia" : "Hesychia";
// Cache for the rtl // Cache for the rtl
useMemo(() => { useMemo(() => {
@@ -115,7 +117,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
<Sidenav <Sidenav
color={sidenavColor} color={sidenavColor}
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite} brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
brandName="Hesychia" brandName={brandName}
routes={[]} // {routes} routes={[]} // {routes}
onMouseEnter={handleOnMouseEnter} onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave} onMouseLeave={handleOnMouseLeave}
@@ -136,7 +138,7 @@ const DashboardWrapperLayout = ({ children }: DashboardWrapperLayoutProps): JSX.
<Sidenav <Sidenav
color={sidenavColor} color={sidenavColor}
brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite} brand={(transparentSidenav && !darkMode) || whiteSidenav ? brandDark : brandWhite}
brandName="Hesychia" brandName={brandName}
routes={[]} // {routes} routes={[]} // {routes}
onMouseEnter={handleOnMouseEnter} onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave} onMouseLeave={handleOnMouseLeave}
@@ -0,0 +1,126 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import DeleteAccountSection from './DeleteAccountSection';
const mockDelete = jest.fn();
const mockPost = jest.fn();
const mockClearTokens = jest.fn();
const mockGetRefreshToken = jest.fn();
const mockApplyAccessToken = jest.fn();
const mockNavigate = jest.fn();
const mockSetAccount = jest.fn();
const mockSetAuthentication = jest.fn();
const mockTrackEvent = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
delete: (...args: unknown[]) => mockDelete(...args),
post: (...args: unknown[]) => mockPost(...args),
},
applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args),
}));
jest.mock('../../auth/tokenStorage', () => ({
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
getRefreshToken: (...args: unknown[]) => mockGetRefreshToken(...args),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
jest.mock('../../utils/analytics', () => ({
AnalyticsEvents: {
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
},
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = {
main: '#4a90e2',
darkMode: true,
colors: {
text: '#ffffff',
cardBackground: 'rgba(0,0,0,0.4)',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderSection = () =>
render(
<MemoryRouter>
<ThemeProvider theme={theme}>
<AuthContext.Provider
value={{
authenticated: true,
setAuthentication: mockSetAuthentication,
needsNewPassword: false,
setNeedsNewPassword: () => {},
loading: false,
}}
>
<AccountContext.Provider
value={{
account: { email: 'user@example.com' } as never,
setAccount: mockSetAccount,
}}
>
<DeleteAccountSection />
</AccountContext.Provider>
</AuthContext.Provider>
</ThemeProvider>
</MemoryRouter>
);
describe('DeleteAccountSection', () => {
beforeEach(() => {
mockDelete.mockReset();
mockPost.mockReset();
mockClearTokens.mockReset();
mockGetRefreshToken.mockReset();
mockApplyAccessToken.mockReset();
mockNavigate.mockReset();
mockSetAccount.mockReset();
mockSetAuthentication.mockReset();
mockTrackEvent.mockReset();
mockGetRefreshToken.mockReturnValue('refresh-token');
mockClearTokens.mockResolvedValue(undefined);
mockDelete.mockResolvedValue({ data: { deleted: true } });
mockPost.mockResolvedValue({});
});
it('requires email confirmation then deletes and signs out', async () => {
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: /Delete my account/i }));
expect(await screen.findByTestId('delete-account-modal')).toBeInTheDocument();
await user.type(screen.getByLabelText(/Confirm email/i), 'user@example.com');
await user.click(screen.getByRole('button', { name: /^Delete account$/i }));
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('/user/', {
data: { refresh_token: 'refresh-token' },
});
});
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Started');
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Success');
expect(mockClearTokens).toHaveBeenCalled();
expect(mockApplyAccessToken).toHaveBeenCalledWith(null);
expect(mockSetAuthentication).toHaveBeenCalledWith(false);
expect(mockNavigate).toHaveBeenCalledWith('/signin/');
});
});
@@ -0,0 +1,252 @@
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { logOutRevenueCat } from '../../utils/revenueCat';
const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 1000px;
margin-bottom: 2rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
`;
const CardTitle = styled.h2`
font-size: 1.8rem;
margin-bottom: 1rem;
color: ${({ theme }) => theme.colors.text};
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
padding-bottom: 1rem;
`;
const BodyText = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.75;
margin: 0 0 1rem 0;
line-height: 1.5;
`;
const ErrorText = styled.p`
color: #ff6b6b;
margin: 0.75rem 0 0 0;
font-size: 0.95rem;
`;
const ButtonRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
`;
const DangerButton = styled.button`
background: transparent;
border: 1px solid #ff6b6b;
border-radius: 0.5rem;
color: #ff6b6b;
padding: 0.8rem 1.5rem;
font-weight: 600;
cursor: pointer;
&:hover {
background: #ff6b6b22;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const SecondaryButton = styled.button`
background: transparent;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
color: ${({ theme }) => theme.colors.text};
padding: 0.8rem 1.5rem;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ConfirmInput = styled.input`
width: 100%;
max-width: 420px;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
padding: 0.8rem;
color: ${({ theme }) => theme.colors.text};
font-size: 1rem;
outline: none;
margin-top: 0.5rem;
&:focus {
border-color: #ff6b6b;
}
`;
const ModalBackdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
`;
const ModalCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(12px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 1.75rem;
max-width: 520px;
width: 100%;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
`;
const ModalTitle = styled.h3`
margin: 0 0 0.75rem 0;
color: ${({ theme }) => theme.colors.text};
font-size: 1.35rem;
`;
function apiErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return axiosError.response?.data?.detail || axiosError.message || fallback;
}
const DeleteAccountSection = (): JSX.Element => {
const { account, setAccount } = useContext(AccountContext);
const { setAuthentication } = useContext(AuthContext);
const navigate = useNavigate();
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmEmail, setConfirmEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const email = account?.email || '';
const closeModal = () => {
if (loading) return;
setConfirmOpen(false);
setConfirmEmail('');
setError('');
};
const handleDelete = async () => {
if (!email || confirmEmail.trim().toLowerCase() !== email.toLowerCase()) {
setError('Type your account email exactly to confirm.');
return;
}
setLoading(true);
setError('');
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_STARTED);
try {
const refreshToken = getRefreshToken();
await axiosInstance.delete('/user/', {
data: refreshToken ? { refresh_token: refreshToken } : {},
});
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_SUCCESS);
try {
if (refreshToken) {
await axiosInstance.post('blacklist/', { refresh_token: refreshToken });
}
} catch {
// Account already deleted; local cleanup is enough.
}
await clearTokens();
applyAccessToken(null);
setAuthentication(false);
setAccount(undefined);
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
navigate('/signin/');
} catch (err: unknown) {
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
setError(apiErrorMessage(err, 'Could not delete account. Try again.'));
setLoading(false);
}
};
return (
<>
<GlassCard data-testid="delete-account-section">
<CardTitle>Delete account</CardTitle>
<BodyText>
Permanently deactivate your account. Conversations are hidden and you will
be signed out. This cannot be undone from the app.
</BodyText>
<DangerButton type="button" onClick={() => setConfirmOpen(true)}>
Delete my account
</DangerButton>
</GlassCard>
{confirmOpen ? (
<ModalBackdrop
role="presentation"
onClick={closeModal}
data-testid="delete-account-modal"
>
<ModalCard
role="dialog"
aria-modal="true"
aria-labelledby="delete-account-title"
onClick={(event) => event.stopPropagation()}
>
<ModalTitle id="delete-account-title">Delete your account?</ModalTitle>
<BodyText>
Type <strong>{email || 'your email'}</strong> to confirm. You will lose
access immediately after deletion.
</BodyText>
<ConfirmInput
type="email"
autoComplete="off"
placeholder="Confirm email"
value={confirmEmail}
onChange={(event) => setConfirmEmail(event.target.value)}
disabled={loading}
aria-label="Confirm email"
/>
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
<ButtonRow>
<DangerButton
type="button"
onClick={handleDelete}
disabled={loading || !confirmEmail}
>
{loading ? 'Deleting…' : 'Delete account'}
</DangerButton>
<SecondaryButton type="button" onClick={closeModal} disabled={loading}>
Keep account
</SecondaryButton>
</ButtonRow>
</ModalCard>
</ModalBackdrop>
) : null}
</>
);
};
export default DeleteAccountSection;
@@ -0,0 +1,534 @@
import React, { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import {
DriveConnectIntent,
DriveConnectionKind,
DriveConnectionType,
DriveProvider,
connectDrive,
disconnectDriveConnection,
driveSyncProgressPercent,
fetchDriveConnections,
formatDriveSyncError,
parseResourceIdsInput,
saveDriveResourceSelection,
syncDriveConnection,
waitForDriveSyncSettlement,
} from "../../utils/drive";
const Section = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 1000px;
margin-bottom: 2rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
`;
const SectionTitle = styled.h2`
font-size: 1.8rem;
margin-bottom: 0.5rem;
color: ${({ theme }) => theme.colors.text};
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
padding-bottom: 1rem;
`;
const SectionDescription = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
margin: 0.75rem 0 1.5rem 0;
line-height: 1.5;
`;
const ConnectButtonRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
`;
const ConnectButton = styled.button`
background: ${({ theme }) => theme.main};
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 0.7rem 1.25rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px ${({ theme }) => theme.main}66;
}
`;
const ConnectionList = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const ConnectionCard = styled.div`
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.75rem;
padding: 1.25rem;
`;
const ConnectionHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
`;
const ConnectionTitle = styled.strong`
color: ${({ theme }) => theme.colors.text};
font-size: 1.05rem;
`;
const ConnectionMeta = styled.p`
margin: 0.35rem 0 0 0;
font-size: 0.85rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
`;
const ConnectionActions = styled.div`
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
`;
const SmallButton = styled.button`
background: transparent;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
color: ${({ theme }) => theme.colors.text};
padding: 0.45rem 0.9rem;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
&:hover {
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const DangerButton = styled(SmallButton)`
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.4);
&:hover {
background: rgba(255, 71, 87, 0.1);
}
`;
const ResourceForm = styled.div`
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
`;
const ResourceInput = styled.input`
flex: 1;
min-width: 200px;
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.5rem;
padding: 0.6rem 0.8rem;
color: ${({ theme }) => theme.colors.text};
&::placeholder {
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
}
`;
const ResourceTagList = styled.div`
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
`;
const ResourceTag = styled.span`
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'};
color: ${({ theme }) => theme.colors.text};
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
`;
const EmptyState = styled.p`
color: ${({ theme }) => theme.colors.text};
opacity: 0.6;
`;
const AlertBanner = styled.div<{ $tone: 'error' | 'success' }>`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin: 0 0 1.25rem 0;
padding: 0.85rem 1rem;
border-radius: 0.5rem;
border: 1px solid
${({ $tone }) => ($tone === 'error' ? 'rgba(255, 107, 107, 0.45)' : 'rgba(46, 204, 113, 0.45)')};
background: ${({ $tone }) =>
$tone === 'error' ? 'rgba(255, 71, 87, 0.12)' : 'rgba(46, 204, 113, 0.12)'};
color: ${({ theme }) => theme.colors.text};
font-size: 0.9rem;
line-height: 1.4;
`;
const AlertDismiss = styled.button`
background: transparent;
border: none;
color: inherit;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
padding: 0;
&:hover {
opacity: 1;
}
`;
const SyncErrorText = styled.p`
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
color: #ff6b6b;
line-height: 1.4;
`;
const ProgressWrap = styled.div`
margin-top: 0.85rem;
`;
const ProgressTrack = styled.div`
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'};
`;
const ProgressFill = styled.div<{ $percent: number | null }>`
height: 100%;
width: ${({ $percent }) => ($percent == null ? '40%' : `${$percent}%`)};
border-radius: 999px;
background: ${({ theme }) => theme.main};
transition: width 0.25s ease;
${({ $percent }) =>
$percent == null
? `
animation: sync-indeterminate 1.2s ease-in-out infinite;
@keyframes sync-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
`
: ''}
`;
const ProgressLabel = styled.p`
margin: 0.4rem 0 0 0;
font-size: 0.8rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
`;
const PROVIDER_LABELS: Record<DriveProvider, string> = {
google: "Google Drive",
microsoft: "OneDrive",
};
type PendingAction = 'sync' | 'save' | 'disconnect';
type DriveConnectionsSectionProps = {
kind: DriveConnectionKind;
title: string;
description?: string;
connectIntent: DriveConnectIntent;
onSynced?: () => void;
};
const DriveConnectionsSection = ({
kind,
title,
description,
connectIntent,
onSynced,
}: DriveConnectionsSectionProps): JSX.Element => {
const [connections, setConnections] = useState<DriveConnectionType[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const [syncBanner, setSyncBanner] = useState<{ tone: 'error' | 'success'; message: string } | null>(
null
);
const [resourceInputs, setResourceInputs] = useState<Record<number, string>>({});
const [pendingAction, setPendingAction] = useState<Record<number, PendingAction | undefined>>({});
const loadConnections = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const all = await fetchDriveConnections();
setConnections(all.filter((conn) => (conn.kind || 'personal') === kind));
} catch {
setLoadError(true);
} finally {
setLoading(false);
}
}, [kind]);
useEffect(() => {
loadConnections();
}, [loadConnections]);
const setAction = (id: number, action?: PendingAction) => {
setPendingAction((prev) => ({ ...prev, [id]: action }));
};
const upsertConnection = (updated: DriveConnectionType) => {
setConnections((prev) => {
const exists = prev.some((conn) => conn.id === updated.id);
if (!exists) {
return (updated.kind || 'personal') === kind ? [...prev, updated] : prev;
}
return prev.map((conn) => (conn.id === updated.id ? { ...conn, ...updated } : conn));
});
};
const handleDisconnect = async (id: number) => {
setAction(id, 'disconnect');
try {
await disconnectDriveConnection(id);
setConnections((prev) => prev.filter((conn) => conn.id !== id));
} catch (err) {
console.log(err);
setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' });
} finally {
setAction(id, undefined);
}
};
const handleSync = async (id: number) => {
setAction(id, 'sync');
setSyncBanner(null);
try {
const enqueued = await syncDriveConnection(id);
upsertConnection(enqueued.connection);
const settled =
enqueued.connection.last_sync_status === 'pending'
? await waitForDriveSyncSettlement(id, {
onProgress: (connection) => upsertConnection(connection),
})
: enqueued.connection;
upsertConnection(settled);
if (settled.last_sync_status === 'error') {
setSyncBanner({
tone: 'error',
message: formatDriveSyncError(settled.last_sync_error),
});
} else if (settled.last_sync_status === 'ok') {
setSyncBanner({ tone: 'success', message: 'Drive sync finished.' });
onSynced?.();
}
} catch (err) {
console.error(err);
const message =
err instanceof Error ? err.message : 'Drive sync failed. Try again.';
setSyncBanner({ tone: 'error', message });
await loadConnections();
} finally {
setAction(id, undefined);
}
};
const handleSaveResources = async (id: number) => {
const resourceIds = parseResourceIdsInput(resourceInputs[id] || '');
if (resourceIds.length === 0) {
return;
}
setAction(id, 'save');
try {
const updated = await saveDriveResourceSelection(id, {
resource_ids: resourceIds,
resource_labels: resourceIds,
});
setConnections((prev) => prev.map((conn) => (conn.id === id ? { ...conn, ...updated } : conn)));
setResourceInputs((prev) => ({ ...prev, [id]: '' }));
} catch (err) {
console.log(err);
setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' });
} finally {
setAction(id, undefined);
}
};
return (
<Section>
<SectionTitle>{title}</SectionTitle>
{description && <SectionDescription>{description}</SectionDescription>}
{syncBanner && (
<AlertBanner $tone={syncBanner.tone} role="alert">
<span>{syncBanner.message}</span>
<AlertDismiss
type="button"
aria-label="Dismiss"
onClick={() => setSyncBanner(null)}
>
×
</AlertDismiss>
</AlertBanner>
)}
<ConnectButtonRow>
<ConnectButton
type="button"
onClick={() => {
setConnectError(null);
void connectDrive('google', connectIntent).catch((err) => {
console.error(err);
setConnectError('Could not start Google Drive connect. Try again.');
});
}}
>
Connect Google Drive
</ConnectButton>
<ConnectButton
type="button"
onClick={() => {
setConnectError(null);
void connectDrive('microsoft', connectIntent).catch((err) => {
console.error(err);
setConnectError('Could not start OneDrive connect. Try again.');
});
}}
>
Connect OneDrive
</ConnectButton>
</ConnectButtonRow>
{connectError && <EmptyState>{connectError}</EmptyState>}
{loading ? (
<EmptyState>Loading connections</EmptyState>
) : loadError ? (
<EmptyState>Could not load drive connections. Try again later.</EmptyState>
) : connections.length === 0 ? (
<EmptyState>No drives connected yet.</EmptyState>
) : (
<ConnectionList>
{connections.map((conn) => {
const action = pendingAction[conn.id];
const selectedLabels = conn.selected_resource_labels?.length
? conn.selected_resource_labels
: conn.selected_resource_ids || [];
const isSyncing = action === 'sync' || conn.last_sync_status === 'pending';
const progressPercent = isSyncing ? driveSyncProgressPercent(conn) : null;
const processed = conn.sync_processed ?? 0;
const total = conn.sync_total ?? 0;
return (
<ConnectionCard key={conn.id}>
<ConnectionHeader>
<div>
<ConnectionTitle>{PROVIDER_LABELS[conn.provider] || conn.provider}</ConnectionTitle>
<ConnectionMeta>
{conn.external_account_email || 'Connected account'}
{conn.last_sync_status ? ` · ${conn.last_sync_status}` : ''}
{conn.last_sync_at
? ` · Last synced ${new Date(conn.last_sync_at).toLocaleString()}`
: ''}
</ConnectionMeta>
</div>
<ConnectionActions>
<SmallButton
type="button"
onClick={() => handleSync(conn.id)}
disabled={Boolean(action) || conn.last_sync_status === 'pending'}
>
{isSyncing ? 'Syncing…' : 'Sync now'}
</SmallButton>
<DangerButton
type="button"
onClick={() => handleDisconnect(conn.id)}
disabled={Boolean(action)}
>
{action === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
</DangerButton>
</ConnectionActions>
</ConnectionHeader>
{isSyncing && (
<ProgressWrap aria-label="Drive sync progress">
<ProgressTrack>
<ProgressFill $percent={progressPercent} />
</ProgressTrack>
<ProgressLabel>
{progressPercent == null
? 'Discovering files…'
: `${processed} / ${total} files (${progressPercent}%)`}
</ProgressLabel>
</ProgressWrap>
)}
{conn.last_sync_status === 'error' && conn.last_sync_error && (
<SyncErrorText>{formatDriveSyncError(conn.last_sync_error)}</SyncErrorText>
)}
{selectedLabels.length > 0 && (
<ResourceTagList>
{selectedLabels.map((label, idx) => (
<ResourceTag key={`${conn.id}-${idx}`}>{label}</ResourceTag>
))}
</ResourceTagList>
)}
<ResourceForm>
<ResourceInput
type="text"
placeholder="Folder or file IDs, comma separated"
value={resourceInputs[conn.id] || ''}
onChange={(e) =>
setResourceInputs((prev) => ({ ...prev, [conn.id]: e.target.value }))
}
/>
<SmallButton
type="button"
onClick={() => handleSaveResources(conn.id)}
disabled={action === 'save' || !(resourceInputs[conn.id] || '').trim()}
>
{action === 'save' ? 'Saving…' : 'Save folders'}
</SmallButton>
</ResourceForm>
</ConnectionCard>
);
})}
</ConnectionList>
)}
</Section>
);
};
export default DriveConnectionsSection;
@@ -0,0 +1,127 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import Header2 from './Header2';
import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { resetSubscriptionCache } from '../../hooks/useSubscription';
const mockGet = jest.fn();
const mockPost = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
defaults: { headers: { common: {} as Record<string, string | null> } },
},
applyAccessToken: jest.fn(),
}));
jest.mock('../../utils/revenueCat', () => ({
logOutRevenueCat: () => Promise.resolve(),
}));
const theme = {
main: '#4a90e2',
focus: '#224466',
darkMode: true,
colors: {
text: '#ffffff',
cardBorder: 'rgba(255,255,255,0.1)',
},
};
const renderHeader = (props: { onOpenConversations?: () => void } = {}) =>
render(
<MemoryRouter>
<ThemeProvider theme={theme as never}>
<AuthContext.Provider
value={{
authenticated: true,
setAuthentication: jest.fn(),
needsNewPassword: false,
setNeedsNewPassword: jest.fn(),
loading: false,
}}
>
<AccountContext.Provider value={{ account: undefined, setAccount: jest.fn() }}>
<Header2 {...props} />
</AccountContext.Provider>
</AuthContext.Provider>
</ThemeProvider>
</MemoryRouter>
);
const subscriptionWithRag = (rag: boolean) => ({
data: {
plan: {
slug: rag ? 'pro' : 'standard',
name: rag ? 'Pro' : 'Standard',
features: {
text_generation: true,
image_generation: rag,
rag,
all_future_features: false,
},
},
status: 'active',
source: 'stripe',
needs_checkout: false,
stripe_subscription_id: 'sub_1',
usage: {},
},
});
describe('Header2 (#81 subscription-aware Documents nav link)', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
resetSubscriptionCache();
});
it('hides the Documents link while the plan does not include RAG', async () => {
mockGet.mockResolvedValue(subscriptionWithRag(false));
renderHeader();
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
});
it('shows the Documents link once the plan includes RAG', async () => {
mockGet.mockResolvedValue(subscriptionWithRag(true));
renderHeader();
// Desktop nav + mobile dropdown both render a "Documents" link.
expect(await screen.findAllByText('Documents')).toHaveLength(2);
});
});
describe('Header2 (#87 conversations toggle beside logo)', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
resetSubscriptionCache();
mockGet.mockResolvedValue(subscriptionWithRag(false));
});
it('does not render conversations toggle when callback is omitted', () => {
renderHeader();
expect(screen.queryByRole('button', { name: /open conversations/i })).not.toBeInTheDocument();
});
it('renders conversations toggle next to brand when callback is provided', () => {
const onOpenConversations = jest.fn();
renderHeader({ onOpenConversations });
// Mobile-only via CSS (display:none in jsdom desktop width); query by aria-label.
const toggle = screen.getByLabelText('Open conversations');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hesychia')).toBeInTheDocument();
toggle.click();
expect(onOpenConversations).toHaveBeenCalledTimes(1);
});
});
@@ -5,6 +5,8 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext'; import { AccountContext } from '../../contexts/AccountContext';
import { applyAccessToken, axiosInstance } from '../../../axiosApi'; import { applyAccessToken, axiosInstance } from '../../../axiosApi';
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
import { useSubscription } from '../../hooks/useSubscription';
import { logOutRevenueCat } from '../../utils/revenueCat';
import hesychiaMark from '../../assets/brand/hesychia-mark.png'; import hesychiaMark from '../../assets/brand/hesychia-mark.png';
const HeaderContainer = styled.header` const HeaderContainer = styled.header`
@@ -12,26 +14,80 @@ const HeaderContainer = styled.header`
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
box-sizing: border-box;
padding: 1rem 2rem; padding: 1rem 2rem;
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
z-index: 100; z-index: 100;
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'}; background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
@media (max-width: 768px) {
padding: 0.75rem 1rem;
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
}
`;
const BrandCluster = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
`;
const ConversationsToggle = styled.button`
display: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin: 0;
padding: 0.35rem 0.55rem;
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 0.65rem;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)'};
color: ${({ theme }) => theme.colors.text};
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.1)'};
}
@media (max-width: 768px) {
display: inline-flex;
}
`;
const ConversationsToggleLabel = styled.span`
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
@media (max-width: 380px) {
display: none;
}
`; `;
const Logo = styled.div` const Logo = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
min-width: 0;
cursor: pointer; cursor: pointer;
`; `;
const LogoMark = styled.img` const LogoMark = styled.img`
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
flex-shrink: 0;
display: block; display: block;
border-radius: 50%; border-radius: 50%;
`; `;
@@ -39,7 +95,14 @@ const LogoMark = styled.img`
const LogoWordmark = styled.h4` const LogoWordmark = styled.h4`
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
@media (max-width: 768px) {
font-size: 1.25rem;
}
color: ${({ theme }) => theme.colors.text}; color: ${({ theme }) => theme.colors.text};
background: ${({ theme }) => `linear-gradient(135deg, ${theme.main} 0%, ${theme.focus} 100%)`}; background: ${({ theme }) => `linear-gradient(135deg, ${theme.main} 0%, ${theme.focus} 100%)`};
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -100,7 +163,8 @@ const MobileMenuDropdown = styled.div<{ isOpen: boolean }>`
top: 100%; top: 100%;
left: 0; left: 0;
width: 100%; width: 100%;
background: ${({ theme }) => theme.colors.cardBackground}; box-sizing: border-box;
background: ${({ theme }) => theme.darkMode ? 'rgba(10, 12, 22, 0.97)' : 'rgba(255, 255, 255, 0.97)'};
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
display: flex; display: flex;
@@ -122,6 +186,14 @@ const HamburgerIcon = ({ color }: { color: string }) => (
</svg> </svg>
); );
const ConversationsMenuIcon = ({ color }: { color: string }) => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 12H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 6H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 18H21" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const CloseIcon = ({ color }: { color: string }) => ( const CloseIcon = ({ color }: { color: string }) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M18 6L6 18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
@@ -133,14 +205,22 @@ type Header2Props = {
absolute?: Boolean; absolute?: Boolean;
light?: Boolean; light?: Boolean;
isMini?: Boolean; isMini?: Boolean;
/** Mobile: open conversations drawer. Rendered inline beside logo when set. */
onOpenConversations?: () => void;
} }
const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Props): JSX.Element => { const Header2 = ({
absolute = false,
light = false,
isMini = false,
onOpenConversations,
}: Header2Props): JSX.Element => {
const { setAuthentication } = useContext(AuthContext); const { setAuthentication } = useContext(AuthContext);
const { setAccount } = useContext(AccountContext); const { setAccount } = useContext(AccountContext);
const navigate = useNavigate(); const navigate = useNavigate();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const theme = useTheme(); const theme = useTheme();
const { hasRag } = useSubscription();
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
@@ -151,6 +231,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
applyAccessToken(null); applyAccessToken(null);
setAuthentication(false) setAuthentication(false)
setAccount(undefined); setAccount(undefined);
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
navigate('/signin/') navigate('/signin/')
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -164,16 +245,28 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
return ( return (
<HeaderContainer> <HeaderContainer>
<BrandCluster>
{onOpenConversations && (
<ConversationsToggle
type="button"
onClick={onOpenConversations}
aria-label="Open conversations"
>
<ConversationsMenuIcon color={theme.colors.text} />
<ConversationsToggleLabel>Conversations</ConversationsToggleLabel>
</ConversationsToggle>
)}
<Logo onClick={() => navigate('/')}> <Logo onClick={() => navigate('/')}>
<LogoMark src={hesychiaMark} alt="" /> <LogoMark src={hesychiaMark} alt="" />
<LogoWordmark>Hesychia</LogoWordmark> <LogoWordmark>Hesychia</LogoWordmark>
</Logo> </Logo>
</BrandCluster>
{/* Desktop Nav */} {/* Desktop Nav */}
<Nav> <Nav>
<NavLink onClick={() => navigate('/')}>Dashboard</NavLink> <NavLink onClick={() => navigate('/')}>Dashboard</NavLink>
<NavLink onClick={() => navigate('/account/')}>Account</NavLink> <NavLink onClick={() => navigate('/account/')}>Account</NavLink>
<NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink> {hasRag && <NavLink onClick={() => navigate('/document_storage/')}>Documents</NavLink>}
<NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink> <NavLink onClick={() => navigate('/analytics/')}>Analytics</NavLink>
<NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink> <NavLink onClick={() => navigate('/feedback/')}>Feedback</NavLink>
<SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton> <SignOutButton onClick={handleSignOut}>Sign Out</SignOutButton>
@@ -188,7 +281,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
<MobileMenuDropdown isOpen={isMenuOpen}> <MobileMenuDropdown isOpen={isMenuOpen}>
<NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink> <NavLink onClick={() => handleNavClick('/')}>Dashboard</NavLink>
<NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink> <NavLink onClick={() => handleNavClick('/account/')}>Account</NavLink>
<NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink> {hasRag && <NavLink onClick={() => handleNavClick('/document_storage/')}>Documents</NavLink>}
<NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink> <NavLink onClick={() => handleNavClick('/analytics/')}>Analytics</NavLink>
<NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink> <NavLink onClick={() => handleNavClick('/feedback/')}>Feedback</NavLink>
<SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton> <SignOutButton onClick={() => { handleSignOut(); setIsMenuOpen(false); }}>Sign Out</SignOutButton>
@@ -0,0 +1,361 @@
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import {
IconButton,
Tooltip,
Menu,
MenuItem,
Popover,
Chip,
TextField,
Button,
Stack,
} from '@mui/material';
import ContentCopy from '@mui/icons-material/ContentCopy';
import Check from '@mui/icons-material/Check';
import ThumbUp from '@mui/icons-material/ThumbUp';
import ThumbUpOutlined from '@mui/icons-material/ThumbUpOutlined';
import ThumbDown from '@mui/icons-material/ThumbDown';
import ThumbDownOutlined from '@mui/icons-material/ThumbDownOutlined';
import FileDownload from '@mui/icons-material/FileDownload';
import { copyTextToClipboard } from '../../utils/clipboard';
import { showToast } from '../../utils/toastBus';
import {
clearPromptFeedback,
upsertPromptFeedback,
type PromptRating,
} from '../../utils/promptFeedback';
import {
exportChat,
type ExportFormat,
type ExportTurn,
} from '../../utils/export/exportChat';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import type { Citation } from '../../utils/wsFrames';
const DOWN_REASONS: { code: string; label: string }[] = [
{ code: 'incorrect', label: 'Incorrect' },
{ code: 'out_of_date', label: 'Out of date' },
{ code: 'didnt_follow_instructions', label: "Didn't follow instructions" },
{ code: 'unsafe', label: 'Unsafe' },
{ code: 'other', label: 'Other' },
];
const ActionsRow = styled.div<{ $alwaysVisible: boolean }>`
display: flex;
align-items: center;
gap: 0.15rem;
margin-top: 0.35rem;
min-height: 2rem;
max-width: 100%;
flex-wrap: wrap;
opacity: ${({ $alwaysVisible }) => ($alwaysVisible ? 1 : 0)};
pointer-events: ${({ $alwaysVisible }) => ($alwaysVisible ? 'auto' : 'none')};
transition: opacity 0.15s ease;
@media (hover: hover) and (pointer: fine) {
/* Desktop: revealed via parent :hover / :focus-within when not forced visible */
}
@media (hover: none), (pointer: coarse) {
opacity: 1;
pointer-events: auto;
}
`;
const ActionIconButton = styled(IconButton)`
&& {
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
padding: 0.35rem;
&:hover,
&:focus-visible {
opacity: 1;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
}
&.Mui-disabled {
opacity: 0.35;
}
}
`;
type MessageActionsProps = {
promptId?: number;
rawMarkdown: string;
userCreated: boolean;
isStreaming?: boolean;
isLast?: boolean;
conversationTitle: string;
createdTimestamp?: Date | string | null;
citations?: Citation[];
initialRating?: PromptRating | null;
onRatingChange?: (rating: PromptRating | null) => void;
};
const MessageActions = ({
promptId,
rawMarkdown,
userCreated,
isStreaming = false,
isLast = false,
conversationTitle,
createdTimestamp,
citations = [],
initialRating = null,
onRatingChange,
}: MessageActionsProps): JSX.Element | null => {
const [copied, setCopied] = useState(false);
const [rating, setRating] = useState<PromptRating | null>(initialRating);
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
const [reasonAnchor, setReasonAnchor] = useState<null | HTMLElement>(null);
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [reasonComment, setReasonComment] = useState('');
const [focused, setFocused] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setRating(initialRating);
}, [initialRating, promptId]);
useEffect(() => {
return () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, []);
if (isStreaming) return null;
const alwaysVisible = isLast || focused || Boolean(exportAnchor) || Boolean(reasonAnchor);
const handleCopy = async () => {
try {
await copyTextToClipboard(rawMarkdown);
setCopied(true);
trackEvent(AnalyticsEvents.MESSAGE_COPIED, {
role: userCreated ? 'user' : 'assistant',
});
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch {
showToast('Could not copy message', 'error');
}
};
const applyRating = async (next: PromptRating | null) => {
if (!promptId) {
showToast('Rating unavailable until message is saved', 'info');
return;
}
const prev = rating;
setRating(next);
onRatingChange?.(next);
try {
if (next == null) {
await clearPromptFeedback(promptId);
} else {
await upsertPromptFeedback(promptId, { rating: next });
}
trackEvent(AnalyticsEvents.MESSAGE_RATED, {
rating: next ?? 'cleared',
});
} catch {
setRating(prev);
onRatingChange?.(prev);
showToast('Could not save rating', 'error');
}
};
const handleThumb = async (
next: PromptRating,
event: React.MouseEvent<HTMLElement>,
) => {
if (rating === next) {
await applyRating(null);
return;
}
await applyRating(next);
if (next === 'down') {
setReasonAnchor(event.currentTarget);
setReasonCode(null);
setReasonComment('');
}
};
const submitReason = async () => {
if (!promptId || rating !== 'down') {
setReasonAnchor(null);
return;
}
try {
await upsertPromptFeedback(promptId, {
rating: 'down',
reason: reasonCode ?? undefined,
comment: reasonComment.trim() || undefined,
});
trackEvent(AnalyticsEvents.MESSAGE_RATING_REASON, {
reason: reasonCode ?? 'none',
hasComment: Boolean(reasonComment.trim()),
});
} catch {
showToast('Could not save feedback reason', 'error');
} finally {
setReasonAnchor(null);
}
};
const handleExport = async (format: ExportFormat) => {
setExportAnchor(null);
const turn: ExportTurn = {
role: userCreated ? 'user' : 'assistant',
message: rawMarkdown,
timestamp: createdTimestamp ?? null,
citations: userCreated ? [] : citations,
};
try {
await exportChat({
format,
scope: 'message',
title: conversationTitle,
turns: [turn],
});
trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, {
format,
scope: 'message',
});
} catch (err) {
console.error(err);
showToast('Export failed', 'error');
}
};
return (
<ActionsRow
ref={rowRef}
$alwaysVisible={alwaysVisible}
data-message-actions
onFocusCapture={() => setFocused(true)}
onBlurCapture={(e) => {
if (!rowRef.current?.contains(e.relatedTarget as Node)) {
setFocused(false);
}
}}
>
<Tooltip title={copied ? 'Copied' : 'Copy markdown'}>
<ActionIconButton
aria-label="Copy message as markdown"
size="small"
onClick={() => void handleCopy()}
>
{copied ? <Check fontSize="small" /> : <ContentCopy fontSize="small" />}
</ActionIconButton>
</Tooltip>
{!userCreated && (
<>
<Tooltip title="Thumbs up">
<span>
<ActionIconButton
aria-label="Thumbs up"
size="small"
disabled={!promptId}
onClick={(e) => void handleThumb('up', e)}
>
{rating === 'up' ? (
<ThumbUp fontSize="small" />
) : (
<ThumbUpOutlined fontSize="small" />
)}
</ActionIconButton>
</span>
</Tooltip>
<Tooltip title="Thumbs down">
<span>
<ActionIconButton
aria-label="Thumbs down"
size="small"
disabled={!promptId}
onClick={(e) => void handleThumb('down', e)}
>
{rating === 'down' ? (
<ThumbDown fontSize="small" />
) : (
<ThumbDownOutlined fontSize="small" />
)}
</ActionIconButton>
</span>
</Tooltip>
</>
)}
<Tooltip title="Export">
<ActionIconButton
aria-label="Export message"
size="small"
onClick={(e) => setExportAnchor(e.currentTarget)}
>
<FileDownload fontSize="small" />
</ActionIconButton>
</Tooltip>
<Menu
anchorEl={exportAnchor}
open={Boolean(exportAnchor)}
onClose={() => setExportAnchor(null)}
>
{(
[
['pdf', 'PDF'],
['csv', 'CSV'],
['xlsx', 'Excel (.xlsx)'],
['txt', 'Plain text'],
] as [ExportFormat, string][]
).map(([format, label]) => (
<MenuItem key={format} onClick={() => void handleExport(format)}>
{label}
</MenuItem>
))}
</Menu>
<Popover
open={Boolean(reasonAnchor)}
anchorEl={reasonAnchor}
onClose={() => setReasonAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Stack spacing={1.25} sx={{ p: 1.5, width: 280, maxWidth: '90vw' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
What went wrong? (optional)
</div>
<Stack direction="row" flexWrap="wrap" gap={0.75}>
{DOWN_REASONS.map((reason) => (
<Chip
key={reason.code}
label={reason.label}
size="small"
color={reasonCode === reason.code ? 'primary' : 'default'}
onClick={() => setReasonCode(reason.code)}
/>
))}
</Stack>
<TextField
size="small"
multiline
minRows={2}
placeholder="Additional details (optional)"
value={reasonComment}
onChange={(e) => setReasonComment(e.target.value)}
/>
<Button variant="contained" size="small" onClick={() => void submitReason()}>
Done
</Button>
</Stack>
</Popover>
</ActionsRow>
);
};
export default MessageActions;

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