diff --git a/packages/server/internal/handler/hubs.go b/packages/server/internal/handler/hubs.go
index d5cdda4..80dd62b 100644
--- a/packages/server/internal/handler/hubs.go
+++ b/packages/server/internal/handler/hubs.go
@@ -594,6 +594,14 @@ func (h *HubsHandler) buildHubSummary(r *http.Request, userID, hubID, role strin
lastVisit = visit
}
+ // "there" is kept as the no-name value for BACK-COMPAT with already
+ // deployed web bundles: old clients interpolate greeting_params.name
+ // verbatim, and an empty string would render "Good evening, ."
+ // during any server-first deploy window. New clients treat "there"
+ // (and "") as the no-name sentinel and substitute a
+ // locale-appropriate fallback (hub-header.tsx), which fixes the
+ // English leak into non-English locales. Safe to switch this to ""
+ // once no pre-fallback web bundle is expected in the wild.
userName := "there"
if user, err := h.store.GetUser(userID); err == nil && user != nil {
if display := strings.TrimSpace(user.DisplayName); display != "" {
diff --git a/packages/web/src/app/(app)/home/page.tsx b/packages/web/src/app/(app)/home/page.tsx
index e2021ca..f443735 100644
--- a/packages/web/src/app/(app)/home/page.tsx
+++ b/packages/web/src/app/(app)/home/page.tsx
@@ -1,14 +1,90 @@
-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, useMemo, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { useOnboardingActivation } from "@/hooks/use-onboarding-activation";
+import { useAuth, useActiveHub } from "@/lib/auth";
+import { hubRouteSlug } from "@/lib/hub-from-slug";
+import {
+ getLandingSurfaceHint,
+ landingPathFor,
+ setLandingSurfaceHint,
+} from "@/lib/landing-surface";
+
export default function HomePage() {
- redirect("/brain");
+ const router = useRouter();
+ const { user } = useAuth();
+ const { activeHub } = useActiveHub();
+ const { isActivated, isLoading, isUnknown } = useOnboardingActivation();
+ const resolvedRef = useRef(false);
+
+ // Memories landings resolve against the ACTIVE hub so a /home entry
+ // (brand link, error-page home button) never silently reverts a
+ // team-hub selection to personal.
+ const activeSlug = useMemo(
+ () =>
+ activeHub?.hub && user ? hubRouteSlug(activeHub.hub, user.id) : null,
+ [activeHub, user],
+ );
+
+ useLayoutEffect(() => {
+ if (resolvedRef.current) return;
+ const hint = getLandingSurfaceHint();
+ if (hint) {
+ resolvedRef.current = true;
+ router.replace(landingPathFor(hint, activeSlug));
+ return;
+ }
+ if (isLoading) return;
+ resolvedRef.current = true;
+ if (isUnknown) {
+ // Activation signal unavailable (query error): land on the safe,
+ // honest surface WITHOUT persisting a hint — a persisted guess
+ // would misroute every subsequent cold entry until a successful
+ // fetch overwrote it.
+ router.replace(landingPathFor("memories", activeSlug));
+ return;
+ }
+ 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, activeSlug));
+ }, [isActivated, isLoading, isUnknown, activeSlug, 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/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx
index dc847e5..7da54d1 100644
--- a/packages/web/src/app/(app)/settings/page.tsx
+++ b/packages/web/src/app/(app)/settings/page.tsx
@@ -4,14 +4,24 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSettingsDialog } from "@/contexts/settings-dialog-context";
+/**
+ * `/settings` deep link — opens the settings dialog over the app.
+ *
+ * The dialog state lives in SettingsDialogProvider, mounted in
+ * AppShell ABOVE the route tree, so it survives the redirect. Open it
+ * synchronously in the effect, THEN navigate. The previous version
+ * did the reverse with a 100ms setTimeout — router.replace unmounted
+ * this page and the effect cleanup cleared the timer before it fired,
+ * so the deep link landed on the overview with no dialog at all
+ * (audit D5).
+ */
export default function SettingsPage() {
const router = useRouter();
const { open } = useSettingsDialog();
useEffect(() => {
+ open({ kind: "you", section: "profile" });
router.replace("/home");
- const t = setTimeout(() => open({ kind: "you", section: "profile" }), 100);
- return () => clearTimeout(t);
}, [router, open]);
return null;
diff --git a/packages/web/src/components/bar/expand-search-results.tsx b/packages/web/src/components/bar/expand-search-results.tsx
index a34d539..1973c4b 100644
--- a/packages/web/src/components/bar/expand-search-results.tsx
+++ b/packages/web/src/components/bar/expand-search-results.tsx
@@ -132,6 +132,14 @@ export function ExpandSearchResults() {
const { copied: answerCopied, copy: copyAnswer } = useCopy();
const { copied: contextCopied, copy: copyContext } = useCopy();
const [sourcesExpanded, setSourcesExpanded] = useState(false);
+ // "Save it above to start remembering" is onboarding copy — right
+ // for an empty corpus, wrong for a user with hundreds of memories
+ // whose query simply missed. Pick the hint by whether any
+ // accessible hub holds memories.
+ const corpusIsEmpty = hubs.every(({ memory_count }) => !memory_count);
+ const noResultsHint = corpusIsEmpty
+ ? t.bar.noResultsHint
+ : t.recall.noResultsHint;
const allMemories = useMemo(
() => flattenMemories(memoriesPages),
@@ -946,9 +954,7 @@ export function ExpandSearchResults() {
{!jumpToRows.length && !visibleRows.length && value.trim() ? (
) : null}
>
diff --git a/packages/web/src/components/bar/synthesis-slot.tsx b/packages/web/src/components/bar/synthesis-slot.tsx
index 4ac1f30..66f6ff9 100644
--- a/packages/web/src/components/bar/synthesis-slot.tsx
+++ b/packages/web/src/components/bar/synthesis-slot.tsx
@@ -82,7 +82,12 @@ export function SynthesisSlot({
>
⚠
- {t.bar.ai.error}
+ {/* "sources below are still available" is only true when there
+ ARE sources — with zero results that line sat directly above
+ the "No memories match" empty state, contradicting it. */}
+
+ {sources.length ? t.bar.ai.error : t.bar.ai.errorNoSources}
+
);
diff --git a/packages/web/src/components/features/inbox/inbox-control.tsx b/packages/web/src/components/features/inbox/inbox-control.tsx
index ecce5d4..1588fe3 100644
--- a/packages/web/src/components/features/inbox/inbox-control.tsx
+++ b/packages/web/src/components/features/inbox/inbox-control.tsx
@@ -2035,17 +2035,24 @@ function InboxPanelContent({
}
}
>
- 0 ? (
-
- {pendingItems.length}
-
- ) : undefined
- }
- />
+ {/* Panel surface only: the popover has no other title. On the
+ /inbox page the PageHeader above already says "Inbox" (and
+ mobile adds the top-bar tab title) — a third label inside the
+ card was pure repetition (audit E6). The pending count moves
+ into the PageHeader subtitle territory via the rows below. */}
+ {surface === "panel" && (
+ 0 ? (
+
+ {pendingItems.length}
+
+ ) : undefined
+ }
+ />
+ )}
{showQueryError ? (
diff --git a/packages/web/src/components/features/memory-detail/memory-detail-view.tsx b/packages/web/src/components/features/memory-detail/memory-detail-view.tsx
index 851cfa1..fcdbc55 100644
--- a/packages/web/src/components/features/memory-detail/memory-detail-view.tsx
+++ b/packages/web/src/components/features/memory-detail/memory-detail-view.tsx
@@ -166,6 +166,38 @@ export function MemoryDetailView({
setBodySaveError(null);
}, []);
+ // `e` enters body edit (Linear/GitHub convention for "edit the thing
+ // I'm looking at"). Plain keypress only — no modifiers — and never
+ // while typing in an input/textarea/contenteditable or while an
+ // overlay owns focus. Registered unconditionally (keyboard guards
+ // are capability-based, not isMobile-based).
+ useEffect(() => {
+ if (!canEditBody || editingBody) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "e" || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)
+ return;
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable ||
+ // Overlay guard: when a dialog / menu / popover / listbox owns
+ // focus, `e` belongs to that surface — flipping the body into
+ // edit mode underneath it would leave a surprise editor when
+ // the overlay closes.
+ target.closest(
+ '[role="dialog"], [aria-modal="true"], [role="menu"], [role="listbox"], [data-slot="popover"], [popover]',
+ ))
+ )
+ return;
+ e.preventDefault();
+ handleEditBody();
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [canEditBody, editingBody, handleEditBody]);
+
const handleSaveBody = useCallback(
(markdown: string) => {
if (!memory) return;
@@ -536,7 +568,29 @@ export function MemoryDetailView({
saving={updateMemory.isPending}
/>
) : (
- <>
+ // group/body scopes the hover-reveal edit affordance to
+ // the body block. Discoverability fix (audit D1): edit
+ // used to live ONLY behind the ⋯ menu — content looked
+ // inert. The pencil surfaces on hover (always visible on
+ // touch, where there is no hover to discover it with);
+ // the ⋯ menu item and the `e` shortcut remain as
+ // parallel paths.
+
);
})()}
+ {/*
+ * Scrim under the engaged bar (desktop only — mobile surfaces own
+ * their own takeover treatments). Without it the expanded bar
+ * floats over full-contrast page content and both layers fight
+ * for legibility (audit D4: onboarding copy bleeding through the
+ * translucent pill). Spotlight/Raycast pattern: results panel
+ * always sits on a dimmed page. Same z tier as the bar, earlier
+ * in DOM order, so the bar paints above it. Clicks land on the
+ * scrim (not the content beneath) and the document-level
+ * click-outside handler dismisses the bar — one click to close,
+ * zero accidental content activations.
+ */}
+ {!isMobile && (
+
+ )}
{/*
* Bar container — ALWAYS MOUNTED.
*
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/landing-surface-sync.tsx b/packages/web/src/components/shell-v2/landing-surface-sync.tsx
new file mode 100644
index 0000000..a93f344
--- /dev/null
+++ b/packages/web/src/components/shell-v2/landing-surface-sync.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+/**
+ * Invisible always-mounted refresher for the landing-surface hint.
+ *
+ * useOnboardingActivation persists the hint whenever its checklist
+ * query resolves — but the hook is otherwise mounted only on /brain,
+ * the chat empty state, and the /home resolver (which unmounts before
+ * the query settles on the hinted fast path). A user who ACTIVATES
+ * while living on the memories surface would keep hint="memories"
+ * forever and never cold-land on /brain. Mounting the hook here, in
+ * the authed shell, keeps the hint fresh on every surface; the
+ * notifications query underneath is the same one the inbox badge
+ * already keeps warm, so this adds no network traffic.
+ */
+
+import { useOnboardingActivation } from "@/hooks/use-onboarding-activation";
+
+export function LandingSurfaceSync() {
+ useOnboardingActivation();
+ 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..b70189b 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,
@@ -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";
@@ -73,10 +74,11 @@ 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();
- const { user } = useAuth();
+ const { user, hubs } = useAuth();
const { activeHub } = useActiveHub();
const settingsPanel = useSettingsPanel();
const inboxBadgeTone =
@@ -100,7 +102,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
@@ -210,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 && (
+