From 32e4d26d6643b13cfa86b15dd0e71f668fb98b67 Mon Sep 17 00:00:00 2001 From: Derek Ye Date: Tue, 7 Jul 2026 03:50:03 +0000 Subject: [PATCH 1/7] web: kill wrong-surface flash on cold entry via neutral /home resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /home → /brain → /h/personal/memories redirect chain painted the brain surface (centered bar, conversations panel, Brain rail tab) for ~300-600ms before flicking to memories on every cold entry — the 'bar renders then flicks into memory view' defect. - /home becomes a client-side entry resolver: it decides between /brain (activated) and /h/personal/memories (everyone else) BEFORE any destination chrome paints, using a persisted landing-surface hint for instant single-hop routing on returning entries. - New lib/landing-surface.ts owns the hint; useOnboardingActivation refreshes it whenever the server activation signal resolves; clearSession clears it with the token keys. - route-helpers: /home is no longer a brain-view route — the shell renders neutral chrome (no bar, no active rail tab, no secondary panel) while resolving; getShellTabForPath(/home) → null. - LeftRail renders collapsed on /home so entry doesn't play an expanded→collapsed width shift on arrival. - recent-navigation: /home is no surface — no brain/memory morph into or out of the resolver. Verified with CDP screencasts: cold entry now goes loader → neutral shell → destination in one hop, both hinted and unhinted. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DaVzvqvcAJiqtgASHYKZ5s --- packages/web/src/app/(app)/home/page.tsx | 71 ++++++++++++++++--- .../web/src/components/shell-v2/left-rail.tsx | 12 +++- .../src/hooks/use-onboarding-activation.ts | 16 ++++- packages/web/src/lib/auth.tsx | 2 + packages/web/src/lib/landing-surface.ts | 53 ++++++++++++++ .../web/src/lib/recent-navigation.test.ts | 17 ++++- packages/web/src/lib/route-helpers.test.ts | 18 +++++ packages/web/src/lib/route-helpers.ts | 29 +++----- 8 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 packages/web/src/lib/landing-surface.ts diff --git a/packages/web/src/app/(app)/home/page.tsx b/packages/web/src/app/(app)/home/page.tsx index e2021ca..1b5106e 100644 --- a/packages/web/src/app/(app)/home/page.tsx +++ b/packages/web/src/app/(app)/home/page.tsx @@ -1,14 +1,69 @@ -import { redirect } from "next/navigation"; +"use client"; /** - * `/home` is the v1 brain landing URL. Plan 24 phase 4b retires v1 - * across the board — no kill switch, no soak. All visits redirect to - * `/brain`, which is the canonical v2 path that the LeftRail Brain - * tab links to. + * `/home` — the app's neutral entry resolver. * - * Server-side `redirect()` avoids the client-side flash that a - * `useEffect` redirect would produce. + * Every "take me to the app" flow funnels here (login, OAuth callback, + * brand link, error-page home buttons, invite/share landings). This + * page owns ONE decision — activated users land on `/brain`, everyone + * else lands on the personal memories overview — and makes it before + * any destination chrome paints. + * + * History: `/home` used to server-redirect to `/brain`, and `/brain` + * then client-redirected non-activated users to + * `/h/personal/memories`. That two-hop chain painted the brain + * surface (centered bar, conversations panel, Brain rail tab) for + * ~300–600ms before flicking to memories — the "bar renders then + * flicks into memory view" defect. `/home` is intentionally NOT a + * brain-view route anymore (see route-helpers.ts): while resolving, + * the shell renders neutral chrome — no bar, no active rail tab, no + * secondary panel — so there is no wrong surface to flash. + * + * Resolution order: + * 1. Local landing-surface hint (written by useOnboardingActivation + * whenever the server signal resolves) — instant, no network. + * 2. No hint (first entry on this browser): wait for the activation + * query, then route. The shell's neutral state stands in; this + * only happens once per browser. + * + * Why router.replace: `/home` must never land in browser history — + * back from the destination should leave the app, not bounce through + * the resolver. */ + +import { useLayoutEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { useOnboardingActivation } from "@/hooks/use-onboarding-activation"; +import { + getLandingSurfaceHint, + landingPathFor, + setLandingSurfaceHint, +} from "@/lib/landing-surface"; + export default function HomePage() { - redirect("/brain"); + const router = useRouter(); + const { isActivated, isLoading } = useOnboardingActivation(); + const resolvedRef = useRef(false); + + useLayoutEffect(() => { + if (resolvedRef.current) return; + const hint = getLandingSurfaceHint(); + if (hint) { + resolvedRef.current = true; + router.replace(landingPathFor(hint)); + return; + } + if (isLoading) return; + resolvedRef.current = true; + const surface = isActivated ? "brain" : "memories"; + // Persist here as well as in the hook: the redirect unmounts this + // page before the hook's passive effect gets a chance to run. + setLandingSurfaceHint(surface); + router.replace(landingPathFor(surface)); + }, [isActivated, isLoading, router]); + + // Neutral by design: the app shell around this page shows the rail + // with no active tab and no bar. Painting a destination skeleton + // here would just recreate the flick this page exists to remove. + return null; } diff --git a/packages/web/src/components/shell-v2/left-rail.tsx b/packages/web/src/components/shell-v2/left-rail.tsx index f779c0e..53bcd9d 100644 --- a/packages/web/src/components/shell-v2/left-rail.tsx +++ b/packages/web/src/components/shell-v2/left-rail.tsx @@ -37,7 +37,7 @@ */ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { motion, useReducedMotion } from "framer-motion"; import { MemaxLogo, @@ -73,6 +73,7 @@ export function LeftRail({ activeTab }: LeftRailProps) { const { secondaryHidden, toggleSecondary, setSecondaryHidden, isHydrated } = useShellState(); const router = useRouter(); + const pathname = usePathname(); const reduceMotion = useReducedMotion(); const { t } = useLocale(); const { data: notificationSummary } = useNotificationSummary(); @@ -100,7 +101,14 @@ export function LeftRail({ activeTab }: LeftRailProps) { activeTab !== null && !secondaryHidden[activeTab], ); - const expanded = !secondaryOpenOnActive; + // `/home` is the neutral entry resolver (see app/(app)/home/page.tsx): + // it exists for a few frames while the landing surface resolves, and + // every destination it routes to opens a secondary panel — i.e. the + // rail lands collapsed. Starting expanded there would play a + // full-width rail that immediately collapses on arrival: a layout + // shift on every cold entry with no information payoff. + const isEntryResolver = pathname === "/home"; + const expanded = !secondaryOpenOnActive && !isEntryResolver; const visualWidth = expanded ? RAIL_WIDTH_EXPANDED : RAIL_WIDTH_COLLAPSED; // Resolve the memories path against the user's active hub. Static-path diff --git a/packages/web/src/hooks/use-onboarding-activation.ts b/packages/web/src/hooks/use-onboarding-activation.ts index 44f9507..55d38fa 100644 --- a/packages/web/src/hooks/use-onboarding-activation.ts +++ b/packages/web/src/hooks/use-onboarding-activation.ts @@ -1,8 +1,9 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import type { ChecklistPayload } from "memax-sdk"; import { useNotifications } from "@/hooks/use-notifications"; +import { setLandingSurfaceHint } from "@/lib/landing-surface"; /** * useOnboardingActivation — shared "is this user past onboarding?" hook. @@ -63,8 +64,19 @@ export function useOnboardingActivation(): { return fiveMemoriesDone && connectAgentDone; }, [notifications.data, notifications.isLoading, notifications.isPending]); + const isLoading = notifications.isLoading || notifications.isPending; + + // Keep the landing-surface hint fresh wherever this hook is mounted + // (brain page, chat empty state, /home resolver, app shell). The + // hint lets the /home entry resolver route in a single hop on the + // next cold entry without waiting for this query. Idempotent write. + useEffect(() => { + if (isLoading) return; + setLandingSurfaceHint(isActivated ? "brain" : "memories"); + }, [isActivated, isLoading]); + return { isActivated, - isLoading: notifications.isLoading || notifications.isPending, + isLoading, }; } diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index 0f52c0d..18039de 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { getPublicMemaxClient } from "@/lib/memax-client"; import { isImpersonating, restoreOriginalSession } from "@/lib/impersonation"; +import { clearLandingSurfaceHint } from "@/lib/landing-surface"; import { queryClient } from "@/lib/query-client"; import { hubListQueryKey } from "@/hooks/use-hubs"; import type { AuthProviderName } from "memax-sdk"; @@ -159,6 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(ACTIVE_HUB_KEY); + clearLandingSurfaceHint(); setUser(null); setHubs([]); setActiveHubId(""); diff --git a/packages/web/src/lib/landing-surface.ts b/packages/web/src/lib/landing-surface.ts new file mode 100644 index 0000000..facdad6 --- /dev/null +++ b/packages/web/src/lib/landing-surface.ts @@ -0,0 +1,53 @@ +/** + * Landing-surface hint — remembers which surface the signed-in user + * should land on (`/brain` for activated users, the personal memories + * overview for everyone else) so the `/home` entry resolver can route + * in a single hop on cold entry. + * + * Why this exists: activation state lives server-side (onboarding + * checklist materializer) and takes a network round-trip to resolve. + * Without a local hint, every cold entry would either (a) wait on the + * query before showing anything, or (b) guess a surface and visibly + * flick to the other one — the "bar renders then flicks into memory + * view" defect. The hint makes the common case (returning user) + * instant; `useOnboardingActivation` refreshes it whenever the real + * signal resolves, so a stale hint self-corrects on the next entry. + * + * This is a routing hint, not an auth artifact — it never gates + * access, it only picks between two authed surfaces. It is cleared in + * `clearSession` alongside the token keys so a different account on + * the same browser starts neutral. + */ + +export type LandingSurface = "brain" | "memories"; + +const LANDING_SURFACE_KEY = "memax_landing_surface"; + +export function getLandingSurfaceHint(): LandingSurface | null { + if (typeof window === "undefined") return null; + try { + const value = localStorage.getItem(LANDING_SURFACE_KEY); + return value === "brain" || value === "memories" ? value : null; + } catch { + return null; + } +} + +export function setLandingSurfaceHint(surface: LandingSurface) { + if (typeof window === "undefined") return; + try { + localStorage.setItem(LANDING_SURFACE_KEY, surface); + } catch {} +} + +export function clearLandingSurfaceHint() { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(LANDING_SURFACE_KEY); + } catch {} +} + +/** Path for a landing surface. Memories always lands on the personal hub. */ +export function landingPathFor(surface: LandingSurface): string { + return surface === "brain" ? "/brain" : "/h/personal/memories"; +} diff --git a/packages/web/src/lib/recent-navigation.test.ts b/packages/web/src/lib/recent-navigation.test.ts index 5c4de3e..8516707 100644 --- a/packages/web/src/lib/recent-navigation.test.ts +++ b/packages/web/src/lib/recent-navigation.test.ts @@ -83,15 +83,26 @@ describe("recent navigation", () => { }); it("requests a brain-to-memory transition only for surface changes", () => { - requestSurfaceTransitionForNavigation("/home", "/memories"); + requestSurfaceTransitionForNavigation("/brain", "/memories"); expect(consumeSurfaceTransition()).toEqual({ kind: "brain-to-memory" }); requestSurfaceTransitionForNavigation("/memories", "/memories/123"); expect(consumeSurfaceTransition()).toBeNull(); }); - it("requests a memory-to-brain transition when returning home", () => { - requestSurfaceTransitionForNavigation("/memories/topics/topic-1", "/home"); + it("requests a memory-to-brain transition when entering the brain surface", () => { + requestSurfaceTransitionForNavigation("/memories/topics/topic-1", "/brain"); expect(consumeSurfaceTransition()).toEqual({ kind: "memory-to-brain" }); }); + + it("treats /home (the neutral entry resolver) as no surface — no morph", () => { + // /home resolves to a destination via router.replace; playing a + // cross-surface morph into or out of it would repaint the exact + // wrong-surface flash the resolver exists to remove. + requestSurfaceTransitionForNavigation("/home", "/memories"); + expect(consumeSurfaceTransition()).toBeNull(); + + requestSurfaceTransitionForNavigation("/memories/topics/topic-1", "/home"); + expect(consumeSurfaceTransition()).toBeNull(); + }); }); diff --git a/packages/web/src/lib/route-helpers.test.ts b/packages/web/src/lib/route-helpers.test.ts index 0878546..b165db9 100644 --- a/packages/web/src/lib/route-helpers.test.ts +++ b/packages/web/src/lib/route-helpers.test.ts @@ -15,8 +15,23 @@ import { buildTopicPath, buildMemoryDetailPath, getShellTabForPath, + isBrainViewRoute, } from "./route-helpers"; +describe("isBrainViewRoute", () => { + it("matches /brain and descendants", () => { + expect(isBrainViewRoute("/brain")).toBe(true); + expect(isBrainViewRoute("/brain/sessions/x")).toBe(true); + }); + it("does NOT match /home — the neutral entry resolver", () => { + // /home renders no bar and no brain chrome while it resolves the + // landing surface; classifying it as brain-view reintroduces the + // wrong-surface flash on cold entry. + expect(isBrainViewRoute("/home")).toBe(false); + expect(isBrainViewRoute("/home/anything")).toBe(false); + }); +}); + describe("isMemoriesRoute", () => { it("matches v1 shapes", () => { expect(isMemoriesRoute("/memories")).toBe(true); @@ -238,6 +253,9 @@ describe("getShellTabForPath", () => { it("returns null for routes not under a tab", () => { expect(getShellTabForPath("/")).toBeNull(); expect(getShellTabForPath("/login")).toBeNull(); + // /home is the neutral entry resolver — no rail tab may light up + // while it decides between /brain and the memories overview. + expect(getShellTabForPath("/home")).toBeNull(); expect(getShellTabForPath("/h/personal")).toBeNull(); // hub root, not a memories route expect(getShellTabForPath("/settings")).toBeNull(); }); diff --git a/packages/web/src/lib/route-helpers.ts b/packages/web/src/lib/route-helpers.ts index 4cb5315..eda3915 100644 --- a/packages/web/src/lib/route-helpers.ts +++ b/packages/web/src/lib/route-helpers.ts @@ -66,22 +66,17 @@ export function isMemoriesOverviewRoute(pathname: string): boolean { } /** - * Brain (a.k.a. Home) surface predicate. Both routes mount the same - * `` component: - * - v1 chrome's "Home" tab → `/home` - * - v2 chrome's "Brain" tab → `/brain` + * Brain surface predicate — `/brain[/...]` only. * - * The bar's view derivation + brain-surface autofocus need to recognize - * BOTH; without this, opening the v2 Brain tab leaves the bar in - * `view="none"` mode and the brain-surface ergonomics break. + * `/home` is deliberately NOT a brain-view route: it is the neutral + * entry resolver (see app/(app)/home/page.tsx). While it decides + * between `/brain` and the memories overview, the shell must render + * neutral chrome — no bar, no active rail tab — so classifying it as + * brain here would repaint the exact wrong-surface flash the resolver + * exists to remove. */ export function isBrainViewRoute(pathname: string): boolean { - return ( - pathname === "/home" || - pathname.startsWith("/home/") || - pathname === "/brain" || - pathname.startsWith("/brain/") - ); + return pathname === "/brain" || pathname.startsWith("/brain/"); } /** @@ -238,11 +233,9 @@ import type { ShellTabId } from "@/components/shell-v2/shell-tabs"; * `/h//memories` route. */ export function getShellTabForPath(pathname: string): ShellTabId | null { - // /home (v1) and /brain (v2) plus any descendants — both render - // BrainView; v2 chrome's Brain tab lights up on either path. v2 - // routes that intentionally fall through to /home (the /agents stub - // redirect, ⌘M jump, brand link, default landing) all show the - // Brain tab as active under v2 chrome. + // /brain plus descendants. `/home` intentionally returns null — it + // is the neutral entry resolver and the rail must show no active + // tab while it decides between /brain and the memories overview. if (isBrainViewRoute(pathname)) return "brain"; // /agents and any descendant if (pathname === "/agents" || pathname.startsWith("/agents/")) From 35230c5ea3ab126db952de91d33d678982e4058d Mon Sep 17 00:00:00 2001 From: Derek Ye Date: Tue, 7 Jul 2026 03:56:01 +0000 Subject: [PATCH 2/7] web: silent session restore via session-presence cookie + middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previously-signed-in user tapping 'Sign in' on the landing page went through a full reload → login loader → brain flash → memories chain. Root cause: the session lives in localStorage, so neither middleware nor server components could see it — every signed-out surface had to render first and correct itself client-side after auth init. - New lib/session-presence.ts: a secretless marker cookie (memax_session_presence=1) planted whenever tokens are stored or a session is verified (login, token refresh, successful /me), cleared with the session. Existing sessions migrate on their first /me. - middleware.ts: /, /login, /register redirect straight to the /home entry resolver when the cookie is present — signed-in users land in the app in one hop with zero credential re-entry and never see the marketing page or login form. Stale cookie degrades to one bounce through /login (auth init clears it; no loop). - Tests: middleware presence fast-path cases + cookie/normalization independence. Verified with Playwright: / with session → memories directly (landing never paints); 'Sign in' tap from a pre-cookie landing → app in ~1.2s, login form never visible. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DaVzvqvcAJiqtgASHYKZ5s --- packages/web/src/lib/auth.tsx | 12 ++++++++ packages/web/src/lib/memax-client.ts | 6 ++++ packages/web/src/lib/session-presence.ts | 39 ++++++++++++++++++++++++ packages/web/src/middleware.test.ts | 37 ++++++++++++++++++++-- packages/web/src/middleware.ts | 35 ++++++++++++++++++--- 5 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/lib/session-presence.ts diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index 18039de..f31b965 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -12,6 +12,10 @@ import { import { getPublicMemaxClient } from "@/lib/memax-client"; import { isImpersonating, restoreOriginalSession } from "@/lib/impersonation"; import { clearLandingSurfaceHint } from "@/lib/landing-surface"; +import { + clearSessionPresence, + markSessionPresence, +} from "@/lib/session-presence"; import { queryClient } from "@/lib/query-client"; import { hubListQueryKey } from "@/hooks/use-hubs"; import type { AuthProviderName } from "memax-sdk"; @@ -161,6 +165,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(ACTIVE_HUB_KEY); clearLandingSurfaceHint(); + clearSessionPresence(); setUser(null); setHubs([]); setActiveHubId(""); @@ -186,6 +191,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { (accessToken: string, refreshToken: string) => { localStorage.setItem(TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_KEY, refreshToken); + // Server-visible session marker — lets the Edge middleware route + // signed-in users straight into the app from / and /login. + markSessionPresence(); }, [], ); @@ -210,6 +218,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (requestID !== sessionVersionRef.current) { return false; } + // Session verified — (re)plant the presence cookie. Covers + // browsers whose tokens predate the cookie (set only at + // store/refresh time) so they pick up middleware fast-routing. + markSessionPresence(); if ("user" in data) { setUser({ ...data.user, diff --git a/packages/web/src/lib/memax-client.ts b/packages/web/src/lib/memax-client.ts index 69425bf..888f1bd 100644 --- a/packages/web/src/lib/memax-client.ts +++ b/packages/web/src/lib/memax-client.ts @@ -2,6 +2,10 @@ import { Memax } from "memax-sdk"; import { getAccessToken } from "@/lib/auth"; +import { + clearSessionPresence, + markSessionPresence, +} from "@/lib/session-presence"; import { API_URL } from "@/lib/urls"; const BROWSER_API_PROXY_URL = "/api/proxy"; @@ -43,11 +47,13 @@ function getRefreshToken(): string | null { function setStoredTokens(accessToken: string, refreshToken: string) { localStorage.setItem(TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_KEY, refreshToken); + markSessionPresence(); } function clearStoredTokens() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); + clearSessionPresence(); } async function refreshAccessToken(): Promise { diff --git a/packages/web/src/lib/session-presence.ts b/packages/web/src/lib/session-presence.ts new file mode 100644 index 0000000..c095b07 --- /dev/null +++ b/packages/web/src/lib/session-presence.ts @@ -0,0 +1,39 @@ +/** + * Session-presence cookie — a server-visible "this browser has a + * session" marker for the auth tokens that live in localStorage. + * + * The tokens themselves never leave localStorage; this cookie carries + * no secret (literal value "1"). Its only job is to let the Edge + * middleware route a previously-signed-in user straight into the app + * from `/`, `/login`, and `/register` — silent session restore with + * zero marketing-page or login-form flash. Without a server-visible + * signal, those routes must always render the signed-out experience + * first and correct themselves client-side after auth init (a full + * reload + loader chain the user reads as "I had to sign in again"). + * + * Lifecycle: set whenever tokens are persisted or a session is + * verified (login, token refresh, successful /me), cleared whenever + * the session is torn down. If the cookie is ever stale (present with + * dead tokens), the app shell's auth init fails, clears it, and lands + * on /login — one extra hop, no loop. If it is missing with live + * tokens (pre-migration browsers), the client-side redirect fallback + * still works and the next refresh/verify re-plants it. + */ + +export const SESSION_PRESENCE_COOKIE = "memax_session_presence"; + +// ~400 days — matches the practical ceiling browsers apply to +// cookie lifetimes (Chrome caps at 400 days). The cookie re-plants on +// every token refresh, so real sessions never age it out. +const MAX_AGE_SECONDS = 400 * 24 * 60 * 60; + +export function markSessionPresence() { + if (typeof document === "undefined") return; + const secure = window.location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${SESSION_PRESENCE_COOKIE}=1; path=/; max-age=${MAX_AGE_SECONDS}; samesite=lax${secure}`; +} + +export function clearSessionPresence() { + if (typeof document === "undefined") return; + document.cookie = `${SESSION_PRESENCE_COOKIE}=; path=/; max-age=0; samesite=lax`; +} diff --git a/packages/web/src/middleware.test.ts b/packages/web/src/middleware.test.ts index 3f93702..8018f70 100644 --- a/packages/web/src/middleware.test.ts +++ b/packages/web/src/middleware.test.ts @@ -2,9 +2,16 @@ import { describe, it, expect } from "vitest"; import { NextRequest } from "next/server"; import { middleware } from "./middleware"; -function makeRequest(pathname: string): NextRequest { +function makeRequest( + pathname: string, + opts: { sessionPresence?: boolean } = {}, +): NextRequest { const url = `https://memax.app${pathname}`; - return new NextRequest(url); + return new NextRequest(url, { + headers: opts.sessionPresence + ? { cookie: "memax_session_presence=1" } + : undefined, + }); } /** @@ -63,3 +70,29 @@ describe("middleware legacy path normalization", () => { expect(res.status).toBe(200); }); }); + +describe("middleware silent session restore", () => { + it.each(["/", "/login", "/register"])( + "redirects %s to /home when the session-presence cookie is set", + (path) => { + const res = middleware(makeRequest(path, { sessionPresence: true })); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe("https://memax.app/home"); + }, + ); + + it.each(["/", "/login", "/register"])( + "leaves %s alone without the cookie", + (path) => { + const res = middleware(makeRequest(path)); + expect(res.status).toBe(200); + }, + ); + + it("does not let the cookie affect legacy path normalization", () => { + const res = middleware(makeRequest("/memories", { sessionPresence: true })); + expect(res.headers.get("location")).toBe( + "https://memax.app/h/personal/memories", + ); + }); +}); diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index fe38089..1dd3132 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -37,6 +37,13 @@ import { NextRequest, NextResponse } from "next/server"; const V1_MEMORIES_OVERVIEW_RE = /^\/memories\/?$/; const V1_MEMORIES_TOPIC_RE = /^\/memories\/topics\/([^/]+)\/?$/; +// Session-presence fast path (see lib/session-presence.ts). Name is +// duplicated here rather than imported to keep the middleware +// self-contained for the Edge runtime; lib/session-presence.ts and +// middleware.test.ts both pin it. +const SESSION_PRESENCE_COOKIE = "memax_session_presence"; +const SIGNED_OUT_ENTRY_PATHS = new Set(["/", "/login", "/register"]); + function v1ToV2Path(pathname: string): string | null { // /memories or /memories/ — the overview if (V1_MEMORIES_OVERVIEW_RE.test(pathname)) { @@ -52,7 +59,24 @@ function v1ToV2Path(pathname: string): string | null { } export function middleware(request: NextRequest) { - const target = v1ToV2Path(request.nextUrl.pathname); + const { pathname } = request.nextUrl; + + // Silent session restore. When the session-presence cookie says this + // browser already has a session (see lib/session-presence.ts), the + // signed-out entry surfaces redirect straight to the `/home` entry + // resolver: a previously-signed-in user who lands on `/` or taps + // "Sign in" never sees the marketing page or login form — they land + // in the app in one hop with zero credential re-entry. The cookie + // carries no secret; if it is ever stale, the app shell's auth init + // fails, clears it, and bounces to /login exactly once (no loop). + if ( + SIGNED_OUT_ENTRY_PATHS.has(pathname) && + request.cookies.get(SESSION_PRESENCE_COOKIE)?.value === "1" + ) { + return NextResponse.redirect(new URL("/home", request.nextUrl.origin)); + } + + const target = v1ToV2Path(pathname); if (!target) return NextResponse.next(); // Build a fresh URL with the target pathname instead of cloning + @@ -66,9 +90,10 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(redirectUrl); } -// Match ONLY the legacy /memories tree. Other paths (/h/*, /brain, -// /agents, /inbox, /api/*, _next, etc.) skip the middleware entirely -// so it stays cheap. +// Match the legacy /memories tree plus the signed-out entry surfaces +// (/, /login, /register) for the session-presence fast path. Other +// paths (/h/*, /brain, /agents, /inbox, /api/*, _next, etc.) skip the +// middleware entirely so it stays cheap. export const config = { - matcher: ["/memories", "/memories/:path*"], + matcher: ["/", "/login", "/register", "/memories", "/memories/:path*"], }; From 83b6fc0d0a6ee6f6cf8212edda8030a4d85aed6e Mon Sep 17 00:00:00 2001 From: Derek Ye Date: Tue, 7 Jul 2026 04:09:20 +0000 Subject: [PATCH 3/7] =?UTF-8?q?web:=20hub=20switcher=20=E2=80=94=20global?= =?UTF-8?q?=20placement,=20instant=20switch,=20consistent=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement (audit C1): the only desktop switcher trigger lived inside the topic panel header on memories routes — brain/agents/inbox/dreams had no hub affordance at all, and mobile had one only on memories. - LeftRail gains a hub anchor (badge + tooltip + popover switcher) under the brand mark, present on every surface — Slack/Linear pattern: workspace identity lives in the nav rail. Reuses HubIdentityChip via a new 'rail' variant so all switch/create logic stays in one component. - MobileTopBar shows a compact hub badge in the trailing corner on non-memories tabs (memories keeps its title-position chip), opening the existing bottom sheet. Behavior (C2): switching held a dimmed 'Switching to…' overlay for a 480ms minimum even when the destination was cache-warm, while a '✓ Switched' toast fired simultaneously — contradictory double feedback with artificial latency. - Overlay hold is skipped when the warm-up settles within 120ms (the 200ms fade is the whole transition); min-hold only applies to real waits. - Removed the immediate success toast from the switch path — the overlay names the hub and the destination header is the confirmation. Consistency (C3/C4): - Switching from a non-hub route pushed '/memories', which middleware rewrote to /h/personal/... and silently reverted team-hub selections to personal. All switch paths (menu + create-hub flows) now navigate to the TARGET hub's v2 slug. - topic detail/memories query keys now carry the hub scope (prefix pattern: writers patch via getQueriesData/invalidateQueries which are prefix-matching), so a cache entry fetched under one hub can never satisfy a read under another. - Switcher menu rows show the memory count for every hub (the old 'Show X's recent' hint line hid it on inactive rows). - Dropped the dead '/home' warm contract (entry resolver renders no hub data). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DaVzvqvcAJiqtgASHYKZ5s --- .../features/hub/hub-identity-chip.tsx | 83 ++++++++++++++----- .../features/hub/hub-switcher-menu.tsx | 52 +++++------- .../components/shell-v2/app-shell-client.tsx | 11 ++- .../shell-v2/hub-switcher-bottom-sheet.tsx | 14 ++-- .../web/src/components/shell-v2/left-rail.tsx | 24 +++++- .../components/shell-v2/mobile-top-bar.tsx | 22 +++++ packages/web/src/hooks/use-topic-move.ts | 53 +++++++----- packages/web/src/hooks/use-topics.ts | 11 ++- .../web/src/lib/hub-route-readiness.test.ts | 25 ++---- packages/web/src/lib/hub-route-readiness.ts | 24 +----- .../web/src/lib/memory-event-hydration.ts | 28 +++++-- 11 files changed, 213 insertions(+), 134 deletions(-) diff --git a/packages/web/src/components/features/hub/hub-identity-chip.tsx b/packages/web/src/components/features/hub/hub-identity-chip.tsx index 9bf2b62..538bcd6 100644 --- a/packages/web/src/components/features/hub/hub-identity-chip.tsx +++ b/packages/web/src/components/features/hub/hub-identity-chip.tsx @@ -5,12 +5,15 @@ import { createPortal } from "react-dom"; import { ChevronDown } from "lucide-react"; import { useRouter, usePathname } from "next/navigation"; import { hubRouteSlug } from "@/lib/hub-from-slug"; -import { buildMemoriesPath, getHubSlugForPath } from "@/lib/route-helpers"; +import { buildMemoriesPath } from "@/lib/route-helpers"; import { pillClass, Popover, PopoverContent, PopoverTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@memaxlabs/ui"; import { HubSwitcherMenu } from "@/components/features/hub/hub-switcher-menu"; import { HubBadge } from "@/components/features/hub/hub-badge"; @@ -29,7 +32,16 @@ interface HubIdentityChipProps { icon?: string; accent?: string; role?: string; - variant?: "standalone" | "embedded"; + /** + * - `standalone` — glass pill with badge + name (panel headers). + * - `embedded` — inline text-weight trigger. + * - `rail` — badge-only square for the LeftRail: the global, + * always-present hub anchor (Slack/Linear pattern: workspace + * identity lives in the nav rail on every surface, not inside one + * tab's panel). Popover opens to the right; hub name surfaces via + * tooltip like the rail's other icon-only affordances. + */ + variant?: "standalone" | "embedded" | "rail"; viewerName?: string; viewerDisplayName?: string; } @@ -124,22 +136,47 @@ export function HubIdentityChip({ className: "max-w-full glass-pill rounded-full", }); + const isRail = variant === "rail"; + const trigger = isRail ? ( + + + + ) : ( + + + {label} + {role && } + + + ); + return ( - - - {label} - {role && } - - - + {isRail ? ( + + + + {label} + + + ) : ( + trigger + )} +
setOpen(false)} @@ -203,13 +240,13 @@ export function HubIdentityChip({ waitFor: warmPromise, }); switchHub(createdHub.id); - // Match the new hub's URL when on a v2 hub-scoped - // route. Falls through to the v1 overview otherwise. - const onV2Route = getHubSlugForPath(pathname) !== null; - const destination = - onV2Route && user - ? buildMemoriesPath(hubRouteSlug(createdHub, user.id)) - : "/memories"; + // Always navigate to the created hub's v2 slug — + // the "/memories" fallback bounced through the + // middleware rewrite to /h/personal/... and + // silently reverted the switch to personal. + const destination = user + ? buildMemoriesPath(hubRouteSlug(createdHub, user.id)) + : "/memories"; router.push(destination); setBarNotification({ type: "success", diff --git a/packages/web/src/components/features/hub/hub-switcher-menu.tsx b/packages/web/src/components/features/hub/hub-switcher-menu.tsx index 54119fc..c3b7e9b 100644 --- a/packages/web/src/components/features/hub/hub-switcher-menu.tsx +++ b/packages/web/src/components/features/hub/hub-switcher-menu.tsx @@ -2,10 +2,9 @@ import { useState } from "react"; import { Check, Plus } from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { MenuItem } from "@memaxlabs/ui"; import { useAuth } from "@/lib/auth"; -import { useBar } from "@/contexts/bar-context"; import { useLocale } from "@/i18n"; import { useHubs } from "@/hooks/use-hubs"; import { HubBadge } from "@/components/features/hub/hub-badge"; @@ -13,7 +12,7 @@ import { startLiveSurfaceTransition } from "@/lib/recent-navigation"; import { warmHubLandingRoute } from "@/lib/hub-route-readiness"; import { getHubDisplayInitial, getHubDisplayName } from "@/lib/hub-display"; import { hubRouteSlug } from "@/lib/hub-from-slug"; -import { buildMemoriesPath, getHubSlugForPath } from "@/lib/route-helpers"; +import { buildMemoriesPath } from "@/lib/route-helpers"; interface HubSwitcherMenuProps { onSelect?: () => void; @@ -37,8 +36,6 @@ export function HubSwitcherMenu({ }: HubSwitcherMenuProps) { const { user, hubs, activeHubId, switchHub } = useAuth(); const router = useRouter(); - const pathname = usePathname(); - const { setBarNotification } = useBar(); const { t } = useLocale(); const { data: liveHubs } = useHubs(); const [pendingHubId, setPendingHubId] = useState(null); @@ -63,9 +60,6 @@ export function HubSwitcherMenu({ ? { name: user.name, displayName: user.display_name } : undefined, ); - const hint = !isActive - ? t.hubs.showRecentHint?.replace("{name}", label) - : null; const kind = hub.hub_type === "team" ? "team" : "personal"; const showPersonalTag = kind === "personal"; return ( @@ -91,25 +85,19 @@ export function HubSwitcherMenu({ waitFor: warmPromise, }); switchHub(hub.id); - // On v2 hub-scoped routes (`/h//...`) navigate to the - // target hub's slug so the URL matches the new active hub. - // On v1 routes the legacy /memories overview reads the - // active hub from auth context, so the bare path still - // works. - const onV2Route = getHubSlugForPath(pathname) !== null; - const destination = - onV2Route && user - ? buildMemoriesPath(hubRouteSlug(hub, user.id)) - : "/memories"; + // Always navigate to the TARGET hub's v2 slug. The old + // "/memories" fallback for non-v2 routes bounced through + // the middleware rewrite to /h/personal/..., which made + // useV2RouteHubSync silently revert a team-hub selection + // back to personal. + const destination = user + ? buildMemoriesPath(hubRouteSlug(hub, user.id)) + : "/memories"; router.push(destination); - setBarNotification({ - type: "success", - message: - t.hubs.switchedTo?.replace("{name}", label) ?? - `Switched to ${label}`, - autoDismissMs: 2000, - onDismiss: () => setBarNotification(null), - }); + // No success toast: the transition overlay already names + // the destination hub, and the hub header IS the + // confirmation. Firing a "Switched" toast here raced the + // "Switching…" overlay — both were visible at once. setPendingHubId(null); onSelect?.(); }} @@ -123,17 +111,17 @@ export function HubSwitcherMenu({
+ {/* Every row shows its memory count — same metadata for + active and inactive hubs (the old "Show X's recent" + hint line read as mystery-meat and hid the count). */}
{label} {showPersonalTag && } - {!hint && ( - - {memory_count ?? 0} - - )} + + {memory_count ?? 0} + {isActive && }
- {hint &&

{hint}

}
); diff --git a/packages/web/src/components/shell-v2/app-shell-client.tsx b/packages/web/src/components/shell-v2/app-shell-client.tsx index ce79633..f6fc5a4 100644 --- a/packages/web/src/components/shell-v2/app-shell-client.tsx +++ b/packages/web/src/components/shell-v2/app-shell-client.tsx @@ -191,13 +191,22 @@ function AppShell({ children }: { children: React.ReactNode }) { const holdMs = liveTransition.minDurationMs ?? 420; const fadeMs = 200; const maxMs = liveTransition.maxDurationMs ?? 2000; + // When the destination data settles almost immediately (cache-warm + // hub switch), the overlay would otherwise sit dimmed for the full + // min-hold announcing a wait that isn't happening — perceived + // latency added by the loading UI itself. Under this threshold we + // drop the hold and let the 200ms opacity fade be the entire + // transition. The min-hold only applies when there is a real wait, + // where it keeps the pill legible instead of flash-blinking. + const instantThresholdMs = 120; let settled = false; const finish = () => { if (settled) return; settled = true; const elapsed = performance.now() - startedAt; - const remaining = Math.max(0, holdMs - elapsed); + const remaining = + elapsed < instantThresholdMs ? 0 : Math.max(0, holdMs - elapsed); const hideTimeout = window.setTimeout(() => { setLiveTransitionVisible(false); }, remaining); diff --git a/packages/web/src/components/shell-v2/hub-switcher-bottom-sheet.tsx b/packages/web/src/components/shell-v2/hub-switcher-bottom-sheet.tsx index 9ab3e9c..da83232 100644 --- a/packages/web/src/components/shell-v2/hub-switcher-bottom-sheet.tsx +++ b/packages/web/src/components/shell-v2/hub-switcher-bottom-sheet.tsx @@ -29,7 +29,7 @@ import { acquireBodyScrollLock } from "@/lib/scroll-lock"; import { startLiveSurfaceTransition } from "@/lib/recent-navigation"; import { warmHubLandingRoute } from "@/lib/hub-route-readiness"; import { hubRouteSlug } from "@/lib/hub-from-slug"; -import { buildMemoriesPath, getHubSlugForPath } from "@/lib/route-helpers"; +import { buildMemoriesPath } from "@/lib/route-helpers"; import { getHubDisplayInitial, getHubDisplayName } from "@/lib/hub-display"; interface HubSwitcherBottomSheetProps { @@ -136,11 +136,13 @@ export function HubSwitcherBottomSheet({ waitFor: warmPromise, }); switchHub(createdHub.id); - const onV2Route = getHubSlugForPath(pathname) !== null; - const destination = - onV2Route && user - ? buildMemoriesPath(hubRouteSlug(createdHub, user.id)) - : "/memories"; + // Always navigate to the created hub's v2 slug — + // the "/memories" fallback bounced through the + // middleware rewrite to /h/personal/... and + // silently reverted the switch to personal. + const destination = user + ? buildMemoriesPath(hubRouteSlug(createdHub, user.id)) + : "/memories"; router.push(destination); setBarNotification({ type: "success", diff --git a/packages/web/src/components/shell-v2/left-rail.tsx b/packages/web/src/components/shell-v2/left-rail.tsx index 53bcd9d..b70189b 100644 --- a/packages/web/src/components/shell-v2/left-rail.tsx +++ b/packages/web/src/components/shell-v2/left-rail.tsx @@ -48,6 +48,7 @@ import { } from "@memaxlabs/ui"; import { NORMAL, EASE } from "@memaxlabs/ui/tokens/motion"; import { useAuth, useActiveHub } from "@/lib/auth"; +import { HubIdentityChip } from "@/components/features/hub/hub-identity-chip"; import { useShellState } from "@/contexts/shell-state-context"; import { useNotificationSummary } from "@/hooks/use-notifications"; import { useSettingsPanel } from "@/contexts/settings-panel-context"; @@ -77,7 +78,7 @@ export function LeftRail({ activeTab }: LeftRailProps) { const reduceMotion = useReducedMotion(); const { t } = useLocale(); const { data: notificationSummary } = useNotificationSummary(); - const { user } = useAuth(); + const { user, hubs } = useAuth(); const { activeHub } = useActiveHub(); const settingsPanel = useSettingsPanel(); const inboxBadgeTone = @@ -218,6 +219,27 @@ export function LeftRail({ activeTab }: LeftRailProps) { )} + {/* Hub anchor — the global hub identity + switcher, present on + every surface (Slack/Linear pattern: workspace identity lives + in the nav rail, not inside one tab's panel). Gated on 2+ + hubs like every other switcher trigger; single-hub users have + nothing to switch. */} + {hubs.length >= 2 && activeHub && ( +
+ +
+ )} + {/* Tabs */}