Skip to content

fix(web): iOS prechat safe-area + back-nav persistence (LUM-1763)#31416

Merged
ashleeradka merged 2 commits into
mainfrom
ashlee/lum-1763-drift-prechat-ios-safe-area-7461
May 21, 2026
Merged

fix(web): iOS prechat safe-area + back-nav persistence (LUM-1763)#31416
ashleeradka merged 2 commits into
mainfrom
ashlee/lum-1763-drift-prechat-ios-safe-area-7461

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Closes LUM-1763 (partial — #7355 Sanity feature deferred, see status note).

Summary

Back-port of vellum-ai/vellum-assistant-platform#7461. Two iOS prechat polish fixes that landed on platform after the OSS onboarding paths were frozen:

  1. Safe-area top spacing. NameStepScreen and VibeStepScreen were using a flat pt-4, which on iOS slides the back-button row under the status bar / Dynamic Island. Switch to calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 1rem) so the standard 1rem padding sits on top of the system inset.
  2. Back-nav persistence. PreChatFlow resets to screen 0 on every mount, so an iOS user who taps through to the vibe step and hot-reloads (or returns after the OS reclaims memory) is silently dropped back to the name step. Remember the position in sessionStorage under prechat_native_screen; restore on mount; clear on back-from-vibe and on finishNativePreChat. Storage writes are wrapped in try/catch so private-mode contexts don't throw.

LUM-1763 status

Platform PR Status
#7461 (iOS safe-area + back-nav) ✅ this PR
#7355 (Sanity content-automation cohort) ⏭️ deferred — cohort-specific feature with its own Sanity integration. Wants explicit team decision on whether OSS ships the cohort feature at all before porting.

Verified locally

  • bun run lint — 0 errors.
  • bun run typecheck — clean.

Test plan

  • CI green
  • Manual (native/iOS): tap through Name → Vibe step, hot-reload — should land back on Vibe step, not Name step
  • Manual (native/iOS): back from Vibe → Name, hot-reload — should land on Name step (sessionStorage cleared)
  • Manual (native/iOS): visual inspection that the back-button row clears the status bar / Dynamic Island
  • Manual (web): no regression — safe-area inset is 0px on desktop, so padding stays at 1rem

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2


Generated by Claude Code

@linear
Copy link
Copy Markdown

linear Bot commented May 21, 2026

LUM-1763

@ashleeradka ashleeradka marked this pull request as ready for review May 21, 2026 01:39
vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 21, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Value: iOS users can no longer get silently dropped back to the name step after a hot-reload or OS memory reclaim mid-onboarding, and the back-button row no longer clips under the Dynamic Island — two high-friction first-run moments fixed in one shot.

What this does: Five commits bundled around the prechat/onboarding surface: (1) sessionStorage-based screen persistence in PreChatFlow so native iOS users restore to the correct step on reload; (2) safe-area inset fix on both onboarding screens; (3) native retire redirect to /prechat instead of /privacy on iOS; (4) conditional traits-fetch in useAssistantAvatar to avoid 404s on every SSE invalidation when a custom image exists; (5) refactors AssistantIdentity + fetchAssistantIdentity out of chat/api/assistant.ts into src/assistant/identity.ts as a cleaner cross-domain boundary.


pre-chat-flow.tsx — back-nav persistence

Lazy useState initializer restores screen position from sessionStorage, all three write/clear sites are wrapped in try/catch for private-mode safety, and clears on both "back from vibe" and finishNativePreChat. No edge cases missed. One minor thing: onContinue and onSkip both repeat the same setScreen(1) + sessionStorage.setItem block — a small const goToVibeStep = () => { ... } closure inside screen === 0 arm would de-dup that. Non-blocking.

name-step-screen.tsx + vibe-step-screen.tsx — safe-area

calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 1rem) is the right pattern: Capacitor injects --safe-area-inset-top as a CSS variable, the env() fallback covers plain WKWebView, and the 0px fallback keeps desktop at 1rem. Both screens are consistent.

retire-assistant.tsx — native re-onboarding redirect

isNativePlatform() gate is correct — skipping the privacy step for iOS re-onboarding is the right call since consent was already captured at first install.

use-assistant-avatar.ts — conditional traits fetch

Sequential await fetchCharacterTraits(id) gated on imageUrl is intentional and correct inside the React Query queryFn. The test file (use-assistant-avatar.test.tsx) covers both the skip-traits-on-custom-image and fetch-traits-on-no-image paths cleanly.

src/assistant/identity.ts — domain boundary refactor

Moving AssistantIdentity + fetchAssistantIdentity out of the chat domain is the right call — identity is an assistant-level property, not a chat concern. All 5 import sites updated correctly. .cross-domain-allowlist.json updated to remove "chat" from identity-tab.tsx. Clean.

home-markdown-content.tsx — markdown renderer

Design-token styling throughout (--content-secondary, --content-link, --border-base, color-mix). Minor note: no pre override, so fenced code blocks fall back to default react-markdown rendering — probably fine for feed content, but worth a follow-up if multi-line code blocks start appearing in feed items.

Also: the a override renders with target="_blank". On iOS WKWebView without a WKUIDelegate createWebViewWith implementation, target="_blank" links silently do nothing (WKWebView won't navigate to a new "tab"). If the feed can surface external URLs, worth checking that the native client handles them — or swapping to target="_self" on native. Non-blocking for now.

CI — 7/7 green (Lint, Type Check, Build, Test, Socket × 2). ✅


Vellum Constitution — Trust-seeking: users who close the app mid-onboarding return to exactly where they left off, and the UI never slides under the system chrome.

@vex-assistant-bot
Copy link
Copy Markdown
Contributor

@codex review
@devin review this PR

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +38 to +47
const [components, imageUrl] = await Promise.all([
fetchCharacterComponents(id),
fetchCharacterTraits(id),
fetchAvatarImageUrl(id),
]);
// Skip the traits fetch when a custom image exists — the traits
// file is intentionally deleted on the daemon side in that case,
// so requesting it just generates 404s on every SSE-driven
// reconnect invalidation. `AvatarRenderer` only reads `traits`
// when there is no `customImageUrl`.
const traits = imageUrl ? null : await fetchCharacterTraits(id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Sequential traits fetch adds latency for the common case (no custom avatar)

The previous implementation fetched components, traits, and imageUrl all in parallel via Promise.all. The new implementation fetches components and imageUrl in parallel, then fetches traits sequentially only if imageUrl is falsy (apps/web/src/domains/avatar/use-assistant-avatar.ts:47). This means for the majority of users who don't have a custom avatar image, the traits fetch now waits for fetchAvatarImageUrl to resolve before starting — adding one network round-trip of latency to avatar loading. The trade-off (avoiding guaranteed 404s when a custom image exists) is documented in the comment, but reviewers should consider whether the common-case latency regression is acceptable or whether a parallel fetch with post-hoc discard would be preferable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — flagging this as outdated. The avatar fetch change isn't actually in this PR's diff anymore; it shipped via #31407 (merged) where this same trade-off was accepted by reviewers. Quick recap of the reasoning in case it helps anyone reading this thread later:

  • The traits fetch returns a guaranteed 404 when a custom image exists (the daemon deletes the traits file in that branch), and SSE reconnects invalidate SYNC_TAGS.assistantAvatar repeatedly — so the platform was generating a fresh 404 on every reconnect.
  • AvatarRenderer only reads traits when there's no custom image, so the fetch was pure waste in the custom-image branch.
  • The extra RTT only applies in the no-custom-image path, which is where traits is actually needed — and staleTime: Infinity on the query means this runs only on real reconnects, not on every render.

If the latency turns out to matter in practice we can revisit with a parallel-fetch-with-discard pattern; happy to follow up if telemetry says it does.


Generated by Claude Code

ashleeradka pushed a commit that referenced this pull request May 21, 2026
Two non-blocking observations from the vex-bot review on #31412
and #31416 (both files surfaced the same gaps):

1. **Missing element overrides** — react-markdown was falling
   back to browser defaults for tables and fenced code blocks.
   Added overrides for `pre`, `table`, `thead`, `tbody`, `tr`,
   `th`, `td` styled with the home feed's design tokens.
   Tables wrap in a horizontal-scroll container so wide tables
   don't blow out the condensed panel layout.

2. **target="_blank" on iOS WKWebView silently fails** — without
   a `WKUIDelegate createWebViewWith` implementation, the
   webview won't open a new tab. Route through Capacitor's
   `openUrl()` helper (already used elsewhere for OAuth/Stripe
   flows) which presents `SFSafariViewController`. Web keeps the
   default new-tab behavior; the `href` is preserved on both
   platforms so right-click → copy link still works.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2
ashleeradka pushed a commit that referenced this pull request May 21, 2026
Non-blocking observation from the vex-bot review on #31416:
`onContinue` and `onSkip` on the iOS NameStepScreen had the same
4-line body (setScreen + sessionStorage.setItem in try/catch),
which invites drift if one transition path changes and the other
doesn't. Extract a `goToVibeStep` closure inside the screen===0
arm so both callsites point at the same code.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0c5f4ec5e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +81 to +82
const saved = sessionStorage.getItem("prechat_native_screen");
if (saved === "1") return 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope native prechat resume state to the active user

Reading prechat_native_screen unconditionally from sessionStorage means a stale value from a prior onboarding attempt can skip the name step for a different login in the same webview session (for example, user A reaches vibe step, logs out, then user B logs in and lands directly on vibe). Because the key is not user-scoped or cleared on auth transitions, this introduces cross-session state leakage in onboarding flow progression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — real concern. This PR merged before I could push the fix, so I opened #31431 to address it: the persisted key is now prechat_native_screen:${userId}, the read moves into a useLayoutEffect keyed on the user-scoped key (so the restore runs once userId is known, before paint), and all write/clear sites use the same scoped key. If user B logs in after user A left a stale key behind, they read their own key (which doesn't exist) and start at the name step.


Generated by Claude Code

ashleeradka added a commit that referenced this pull request May 21, 2026
… summary (LUM-1759) (#31412)

* refactor(web): lift fetchAssistantIdentity + AssistantIdentity into assistant/ (LUM-1753.4)

These were sitting in `domains/chat/api/assistant.ts` — the
runtime identity of the assistant has nothing to do with chat.
It's a property of the assistant itself, queried by chat, the
identity tab, contacts, intelligence, onboarding, the sync
router, and several stream handlers. Misplaced since day one.

Now lives at `apps/web/src/assistant/identity.ts`. The `chat`
file is renamed only in its header comment — it now solely owns
chat-context bootstrapping (`getChatContext`, `fetchAssistantId`).
The `domains/chat/api/assistant.ts` filename stays for now to
avoid churn; can be renamed in a follow-up if it bothers anyone.

Import sites updated: identity-tab, use-assistant-identity-init,
chat-page (two lines), chat-route-content, contacts-page,
chat-utils.

Cross-domain allow-list: 119 → 118 imports. Small numeric drop —
the structural win is that `fetchAssistantIdentity` no longer
sits in another feature's folder.

Verified locally: lint, typecheck, tests all green.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2

* fix(web): skip avatar character-traits fetch when custom image exists (LUM-1764)

Back-port of vellum-ai/vellum-assistant-platform#7468.

`useAssistantAvatar()` was unconditionally fetching character
traits alongside the components blob and avatar image. When an
assistant has a custom avatar image, the traits file is
intentionally deleted on the daemon side — so every SSE
reconnect (which invalidates `SYNC_TAGS.assistantAvatar`)
produced a fresh 404 on the traits endpoint. 308 of them in
the platform team's local daemon log.

`AvatarRenderer` only reads `traits` when there is no custom
image, so the fetch was pure waste in that branch. Switch to:
- Fetch components + imageUrl in parallel first.
- If imageUrl is non-null, skip the traits fetch entirely.
- Otherwise fall through to the original behavior.

No new APIs, no behavior change for assistants without a
custom image.

Adds a unit test mirroring the platform PR's coverage:
- skips traits fetch when image resolves
- still fetches traits when no image

The other half of LUM-1764 — back-porting platform#7494 (remove
unused pro-upgrade-machine endpoint) — is blocked. The OSS repo
still has an active caller (`compute-upgrade-card.tsx`) that
needs to migrate to the unified machine-resize endpoint first.
Tracking on the issue.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2

* fix(web): home detail panel overflow menu + dynamic greeting + markdown summary (LUM-1759)

Back-port of vellum-ai/vellum-assistant-platform#7467 — the only
PR from LUM-1759's set that wasn't already integrated in OSS
(7413, 7414, 7415, 7416, 7421 had already landed via earlier
home work).

Changes:

- `home-detail-panel.tsx` — replace the inline mark-as-read toggle
  button with an overflow menu (`<Menu.Root>`) that contains both
  "Mark as read / unread" and a new "Dismiss" action. Renames "Go
  to Thread" → "Go to Convo". Adds the required `onDismiss` prop.
- `home-generic-detail.tsx` — render `item.summary` as markdown
  via the new `HomeMarkdownContent` component, so feed items can
  use bold/italic/links/lists/etc. for richer summaries.
- `home-markdown-content.tsx` (new) — `react-markdown` + GFM with
  design-token styling tuned for the condensed detail panel.
- `home-greeting-header.tsx` — accept an optional `greeting` prop
  (daemon-supplied), fall back to the existing static string.
  Wraps both the avatar/greeting and the New Chat button so the
  greeting can truncate cleanly on narrow widths.
- `home-page.tsx` — pass `feedQuery.data?.contextBanner?.greeting`
  to the header and the new `onDismiss={handleDismissItem}` to
  both mobile and desktop detail panel mounts.

The `contextBanner.greeting` field was already on the home feed
response type, so this just wires it through the UI.

Verified locally: lint, typecheck clean.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2

* chore(web): address review feedback on home-markdown-content

Two non-blocking observations from the vex-bot review on #31412
and #31416 (both files surfaced the same gaps):

1. **Missing element overrides** — react-markdown was falling
   back to browser defaults for tables and fenced code blocks.
   Added overrides for `pre`, `table`, `thead`, `tbody`, `tr`,
   `th`, `td` styled with the home feed's design tokens.
   Tables wrap in a horizontal-scroll container so wide tables
   don't blow out the condensed panel layout.

2. **target="_blank" on iOS WKWebView silently fails** — without
   a `WKUIDelegate createWebViewWith` implementation, the
   webview won't open a new tab. Route through Capacitor's
   `openUrl()` helper (already used elsewhere for OAuth/Stripe
   flows) which presents `SFSafariViewController`. Web keeps the
   default new-tab behavior; the `href` is preserved on both
   platforms so right-click → copy link still works.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits May 21, 2026 02:18
…(LUM-1763)

Back-port of vellum-ai/vellum-assistant-platform#7461.

Two related iOS prechat polish fixes that landed on the platform
side after the onboarding paths were frozen:

1. **Safe-area top spacing.** `NameStepScreen` and `VibeStepScreen`
   were using a flat `pt-4`, which on iOS slides the back-button
   row under the status bar / Dynamic Island. Switch to
   `calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 1rem)`
   so the standard 1rem padding sits on top of the system inset.

2. **Back-nav persistence.** `PreChatFlow` resets to screen 0 on
   every mount, so an iOS user who taps through to the vibe step
   and then hot-reloads (or returns after the OS reclaims memory)
   is silently dropped back to the name step. Remember the
   position in `sessionStorage` under `prechat_native_screen`;
   restore on mount; clear on back-from-vibe and on
   finishNativePreChat. Storage writes are wrapped in try/catch
   so private-mode contexts don't throw.

The other PR in LUM-1763 (#7355 — alex-nork's Sanity connection
for the content-automation cohort) is deferred as a cohort-
specific feature, not a small drift fix.

Verified locally: lint, typecheck clean.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2
Non-blocking observation from the vex-bot review on #31416:
`onContinue` and `onSkip` on the iOS NameStepScreen had the same
4-line body (setScreen + sessionStorage.setItem in try/catch),
which invites drift if one transition path changes and the other
doesn't. Extract a `goToVibeStep` closure inside the screen===0
arm so both callsites point at the same code.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2
@ashleeradka ashleeradka force-pushed the ashlee/lum-1763-drift-prechat-ios-safe-area-7461 branch from 58a8b10 to 279c1e2 Compare May 21, 2026 02:19
@ashleeradka ashleeradka merged commit f4420de into main May 21, 2026
7 checks passed
@ashleeradka ashleeradka deleted the ashlee/lum-1763-drift-prechat-ios-safe-area-7461 branch May 21, 2026 02:23
ashleeradka pushed a commit that referenced this pull request May 21, 2026
Follow-up to LUM-1763 (#31416 merged) addressing the Codex P2
comment on that PR:

> Reading prechat_native_screen unconditionally from
> sessionStorage means a stale value from a prior onboarding
> attempt can skip the name step for a different login in the
> same webview session (user A reaches vibe step, logs out, then
> user B logs in and lands directly on vibe).

Fix: key the persisted screen by userId. Read happens in a
`useLayoutEffect` once userId is known (vs the previous lazy
useState initializer that didn't have access to it), so we
restore before paint without risking a flash to the wrong step.
Write/clear sites now use the same user-scoped key.

If the prior session never logged out cleanly, user B reads
"their" key which doesn't exist, and starts fresh at the name
step — no leak.

Verified locally: lint, typecheck clean.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2
ashleeradka added a commit that referenced this pull request May 21, 2026
Follow-up to LUM-1763 (#31416 merged) addressing the Codex P2
comment on that PR:

> Reading prechat_native_screen unconditionally from
> sessionStorage means a stale value from a prior onboarding
> attempt can skip the name step for a different login in the
> same webview session (user A reaches vibe step, logs out, then
> user B logs in and lands directly on vibe).

Fix: key the persisted screen by userId. Read happens in a
`useLayoutEffect` once userId is known (vs the previous lazy
useState initializer that didn't have access to it), so we
restore before paint without risking a flash to the wrong step.
Write/clear sites now use the same user-scoped key.

If the prior session never logged out cleanly, user B reads
"their" key which doesn't exist, and starts fresh at the name
step — no leak.

Verified locally: lint, typecheck clean.

https://claude.ai/code/session_01JPGu4yGPTL4JoLaPuqpEW2

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants