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() ? (

{t.bar.noResults}

-

- {t.bar.noResultsHint} -

+

{noResultsHint}

) : null} @@ -993,9 +999,7 @@ export function ExpandSearchResults() { !recallFetching ? (

{t.bar.noResults}

-

- {t.bar.noResultsHint} -

+

{noResultsHint}

) : 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. +
+ {canEditBody && ( + + )} {hasSummary && (
- +
)} {bodySaveError && editingBody ? (
{ 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); @@ -301,6 +311,7 @@ function AppShell({ children }: { children: React.ReactNode }) { + {/* Global bar + portals + backdrop. */} @@ -916,6 +927,35 @@ function GlobalBar() {
); })()} + {/* + * 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 && ( +
+ +
+ )} + {/* Tabs */}