Compare commits

Author SHA1 Message Date
westfarn d6cfdeb6f0 Default chat to THINKING; open citations in new tab (#106, #108).
Drop Sources card; inline [n] opens the source URL. Lock composer to THINKING while model picker stays hidden.
2026-08-04 12:22:05 -05:00
westfarn a13108b840 Remove FAST/THINKING model dropdown from chat composer (#106).
Unit Tests / test (pull_request) Successful in 12s
Hide mode selection from users; keep sending fixed FAST so the backend contract stays intact.
2026-08-04 12:16:44 -05: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
westfarn e6ae9d6b1e Fix mobile white screen: stop Outlet Date.now() remount loop (#54) (#56)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m33s
## Summary

- Follow-up for #54 (localStorage guard in #55 did not fix mobile white screen)
- Confirmed in production bundle: `ProtectedRoutes` rendered `<Outlet key={Date.now()} />`
- That remounts the entire authenticated tree on **every** parent re-render (account fetch, WebSocket connect/status, conversations, messages)
- On mobile this shows as: brief UI flash → solid white page (matches screen recording)
- Also memoize styled-components theme in `GlobalThemeWrapper` so `ParticleBackground` does not tear down/restart its canvas on unrelated re-renders

## Evidence

Production JS currently contains:
```js
e?(0,ma.jsx)(a.sv,{},Date.now()):(0,ma.jsx)(a.C5,{to:"/signin/",replace:!0})
```
i.e. authenticated → `<Outlet key={Date.now()} />`

## Test plan

- [ ] Mobile Vivaldi/Chrome: sign in on hesychia.ai → dashboard stays visible (header, conversations toggle, input)
- [ ] Pull-to-refresh while logged in → UI returns and stays
- [ ] Desktop login still works; switching conversations / WS reconnect does not blank the page
- [ ] Open mobile menu / Conversations sidebar still worksReviewed-on: #56
2026-07-29 04:04:35 -07:00
westfarn 103ca2c5e4 Fix mobile white screen when localStorage throws on access (#54) (#55)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m24s
## Summary

- Closes #54
- Add `getLocalStorageSafe()` helper in `tokenStorage.js` to handle browsers that expose `localStorage` but throw `SecurityError` on access
- Wrap all JWT token get/set/remove calls in try/catch so auth bootstrap no longer crashes React on mobile

## Problem

On mobile browsers, hesychia.ai flashes the UI briefly after login then goes white. Auth startup reads tokens from `localStorage` without guarding against storage access errors (common in private mode or blocked storage).

## Test plan

- [ ] Open hesychia.ai on mobile Safari/Chrome (normal mode) and sign in — dashboard should render
- [ ] Repeat in private/incognito mode — app should not white-screen (may require re-login each session if storage blocked)
- [ ] Verify existing web login/logout still works on desktop
- [ ] Run `npm test -- tokenStorage` in `llm-fe`Reviewed-on: #55
2026-07-29 03:00:04 -07:00
westfarn 799d3d591f Reject empty/whitespace chat messages before send (#52)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 2m55s
## Summary
- Closes #51
- Trim prompt Yup validation; refuse empty/whitespace in submit handlers and `sendMessage`
- Add WebSocketContext test for empty send rejection

## Test plan
- [x] `WebSocketContext.test.js` empty/whitespace send cases
- [ ] Manually confirm whitespace-only input cannot submit on desktop + mobile viewport
- [ ] Confirm normal messages still send

Related BE: https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/31Reviewed-on: #52
2026-07-28 05:12:39 -07:00
westfarn 86bfbcdb6d Sign-in branding: Hesychia.ai + transparent logo (#49) (#50)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 3m12s
## Summary
Closes #49.

- Show **Hesychia.ai** wordmark with the brand mark on the sign-in page
- Regenerate brand PNGs with true transparent corners (source soft-glow RGBA had no fully clear pixels)
- Update SignIn test for the new heading

## Test plan
- [ ] Open `/signin/` — mark + **Hesychia.ai** above Sign In
- [ ] Confirm no black square around mark on dark particle background
- [ ] `npm test -- --testPathPattern=SignIn.test --watchAll=false`Reviewed-on: #50
2026-07-28 04:42:36 -07:00
westfarn ab5243abff Fix AnalyticsSession import paths for beta build (#47) (#48)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 3m0s
## Summary
- Fixes wrong relative imports in `AnalyticsSession.tsx` (`../` → `../../`) so `npm run build:beta` can resolve contexts/utils.
- Unblocks beta deploy; empty docroot was causing `https://beta.hesychia.ai/` nginx 403.

Fixes #47

## Test plan
- [ ] CI unit tests pass
- [ ] Beta deploy workflow succeeds after merge
- [ ] `https://beta.hesychia.ai/` returns 200 with the SPAReviewed-on: #48
2026-07-28 04:20:14 -07:00
westfarn ca230066f8 Ship Hesychia meander logo for rebrand (#30) (#46)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Failing after 1m49s
## Summary
Closes #30.

- Replace old infinity brand mark with the cyan Greek-key (meander) Hesychia emblem
- Update web favicons / PWA icons (`public/`), Capacitor source art (`assets/`), and regenerate Android launcher + splash densities
- Wire mark into Header / Header2, Sign In / Sign Up, and Sidenav branding

## Test plan
- [ ] Favicon + apple-touch icon show new mark in browser tab / home screen
- [ ] Header and auth screens show mark + Hesychia wordmark
- [ ] `npm run assets:generate` already run; Android launcher / splash use new art
- [ ] Old infinity artwork no longer present in `assets/` / `public/` brand slotsReviewed-on: #46
2026-07-28 03:51:21 -07:00
westfarn 347fa75534 Tianji custom event tracking (auth, billing, chat) (#36) (#45)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 1m16s
## Summary

Closes #36.

Helpers / consent gating already exist from [#42](#42). This PR adds **call sites** + docs:

- `identifyAccount` + `AnalyticsSession` (identify on session; `Logout` when auth ends)
- Auth: Sign In / Sign Up / AuthCallback (SSO) success & fail
- Billing: Checkout started, Payment Success / Canceled
- Chat: Conversation Created, Message Sent (metadata only — no prompt text)
- ToS Acknowledged
- `BILLING_PORTAL_OPENED` reserved until Account portal (#33)
- Catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md)

No `chat_backend` changes required.

## Test plan

- [ ] Grant consent on beta → sign in → Tianji shows Login Success + identify
- [ ] Decline consent → same actions produce no custom events (page views still ok)
- [ ] Sign up + checkout + cancel/success return URLs fire expected events
- [ ] New chat + send message → Conversation Created / Message Sent without prompt text
- [ ] Acknowledge ToS → ToS Acknowledged
- [ ] `npm run test:ci -- --testPathPattern='analytics.test|SignIn.test|SignUp.test|AuthCallback.test'`Reviewed-on: #45
2026-07-27 08:40:19 -07:00
195 changed files with 12638 additions and 2365 deletions
+1
View File
@@ -229,6 +229,7 @@ See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operat
- **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`) - **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`)
- Consent choice stored in `localStorage` key `hesychia_analytics_consent_v1` (`granted` / `denied`) - Consent choice stored in `localStorage` key `hesychia_analytics_consent_v1` (`granted` / `denied`)
- Banner links to `/terms_of_service/#analytics` for the always-on vs optional split - Banner links to `/terms_of_service/#analytics` for the always-on vs optional split
- Event catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md)
## Related repos ## Related repos
+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
+43
View File
@@ -0,0 +1,43 @@
# Tianji product events (Hesychia)
Helpers: `llm-fe/src/llm-fe/utils/analytics.ts`
Consent gate: custom events + identify only when `hesychia_analytics_consent*` is `granted` (see #37).
Page views: always via `Tracker` / `tracker.js` on prod + beta.
## Event catalog
| Event name | Constant | Call site(s) | Payload (non-PII) |
|------------|----------|--------------|-------------------|
| Login Success | `LOGIN_SUCCESS` | SignIn, AuthCallback | `{ method: 'password' \| 'sso' }` |
| Login Failed | `LOGIN_FAILED` | SignIn, AuthCallback | `{ method }` |
| Sign Up Started | `SIGNUP_STARTED` | SignUp | — |
| Sign Up Success | `SIGNUP_SUCCESS` | SignUp, AuthCallback (`created`) | `{ method? }` |
| Sign Up Failed | `SIGNUP_FAILED` | SignUp, AuthCallback | `{ method? }` |
| Logout | `LOGOUT` | AnalyticsSession (auth → logged out) | — |
| Checkout Started | `CHECKOUT_STARTED` | SignUp, AuthCallback | `{ source }` |
| Payment Success | `PAYMENT_SUCCESS` | BillingSuccess | `{ hasSessionId }` |
| Payment Canceled | `PAYMENT_CANCELED` | BillingCancel | — |
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
| 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
`identifyAccount(account)` sends `{ userId: email, companyId?, isCompanyManager? }`.
No message text, document contents, or names.
## Environments
Only prod/beta (`REACT_APP_DEPLOY_ENV` or production build with Tianji website id). Local dev skips unless env set.
+26 -2
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`:
@@ -50,12 +65,21 @@ Bump both before each store release.
## Icons / splash ## Icons / splash
Source art lives in `llm-fe/assets/` (`icon.png`, `splash.png`, `splash-dark.png`). Regenerate Android densities: Source art lives in `llm-fe/assets/`:
- `icon.png` / `splash.png` / `splash-dark.png` — Capacitor inputs (Hesychia meander mark)
- `logo-source.png` — cropped square master
- `logo-mark-transparent.png` — circular mark with transparent corners (web / in-app)
In-app UI also imports `src/llm-fe/assets/brand/hesychia-mark.png`.
Regenerate Android densities after changing source art:
```bash ```bash
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).
+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
+3 -3
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,7 +105,7 @@ 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. **Testing → Internal testing** → create release → upload `app-release.aab`.
4. Add testers, roll out internal track, verify on devices. 4. Add testers, roll out internal track, verify on devices.
@@ -126,6 +126,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: 26 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

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: 5.9 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+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) |
+1957 -1897
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"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 205 KiB

+8 -1
View File
@@ -22,6 +22,9 @@ import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThe
import Tracker from './llm-fe/components/Tracker/Tracker'; 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 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);
@@ -33,7 +36,7 @@ const ProtectedRoutes = () => {
if (!authenticated) return <Navigate to='/signin/' replace /> if (!authenticated) return <Navigate to='/signin/' replace />
//if(!needsNewPassword) return <Navigate to='/password_reset/' replace/> //if(!needsNewPassword) return <Navigate to='/password_reset/' replace/>
return <Outlet key={Date.now()} /> return <Outlet />
} }
@@ -45,12 +48,15 @@ class App extends Component {
<GlobalThemeWrapper> <GlobalThemeWrapper>
<AnalyticsConsentProvider> <AnalyticsConsentProvider>
<Tracker /> <Tracker />
<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 />} />
@@ -76,6 +82,7 @@ class App extends Component {
</Route> </Route>
</Routes> </Routes>
</AppErrorBoundary>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+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));
+43 -11
View File
@@ -9,6 +9,21 @@
export const ACCESS_TOKEN_KEY = "access_token"; export const ACCESS_TOKEN_KEY = "access_token";
export const REFRESH_TOKEN_KEY = "refresh_token"; export const REFRESH_TOKEN_KEY = "refresh_token";
/**
* Browsers can expose localStorage but throw on access (privacy mode / blocked storage).
* @returns {Storage|null}
*/
function getLocalStorageSafe() {
try {
if (typeof window === "undefined" || !window.localStorage) {
return null;
}
return window.localStorage;
} catch {
return null;
}
}
/** /**
* @returns {{ get?: Function, set?: Function, remove?: Function } | undefined} * @returns {{ get?: Function, set?: Function, remove?: Function } | undefined}
*/ */
@@ -24,10 +39,15 @@ function getPreferencesPlugin() {
* @returns {string|null} * @returns {string|null}
*/ */
export function getToken(key) { export function getToken(key) {
if (typeof localStorage === "undefined") { const storage = getLocalStorageSafe();
if (!storage) {
return null;
}
try {
return storage.getItem(key);
} catch {
return null; return null;
} }
return localStorage.getItem(key);
} }
/** /**
@@ -51,10 +71,15 @@ export function getRefreshToken() {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function setTokens(access, refresh) { export async function setTokens(access, refresh) {
if (typeof localStorage !== "undefined") { const storage = getLocalStorageSafe();
localStorage.setItem(ACCESS_TOKEN_KEY, access); if (storage) {
if (refresh != null) { try {
localStorage.setItem(REFRESH_TOKEN_KEY, refresh); storage.setItem(ACCESS_TOKEN_KEY, access);
if (refresh != null) {
storage.setItem(REFRESH_TOKEN_KEY, refresh);
}
} catch {
/* localStorage unavailable */
} }
} }
@@ -75,9 +100,14 @@ export async function setTokens(access, refresh) {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function clearTokens() { export async function clearTokens() {
if (typeof localStorage !== "undefined") { const storage = getLocalStorageSafe();
localStorage.removeItem(ACCESS_TOKEN_KEY); if (storage) {
localStorage.removeItem(REFRESH_TOKEN_KEY); try {
storage.removeItem(ACCESS_TOKEN_KEY);
storage.removeItem(REFRESH_TOKEN_KEY);
} catch {
/* localStorage unavailable */
}
} }
const Preferences = getPreferencesPlugin(); const Preferences = getPreferencesPlugin();
@@ -110,7 +140,8 @@ export async function hydrateTokensFromNativeStorage() {
try { try {
const result = await Preferences.get({ key: ACCESS_TOKEN_KEY }); const result = await Preferences.get({ key: ACCESS_TOKEN_KEY });
if (result?.value) { if (result?.value) {
localStorage.setItem(ACCESS_TOKEN_KEY, result.value); const storage = getLocalStorageSafe();
storage?.setItem(ACCESS_TOKEN_KEY, result.value);
access = result.value; access = result.value;
} }
} catch { } catch {
@@ -122,7 +153,8 @@ export async function hydrateTokensFromNativeStorage() {
try { try {
const result = await Preferences.get({ key: REFRESH_TOKEN_KEY }); const result = await Preferences.get({ key: REFRESH_TOKEN_KEY });
if (result?.value) { if (result?.value) {
localStorage.setItem(REFRESH_TOKEN_KEY, result.value); const storage = getLocalStorageSafe();
storage?.setItem(REFRESH_TOKEN_KEY, result.value);
refresh = result.value; refresh = result.value;
} }
} catch { } catch {
@@ -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;

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