Skip to content
8 changes: 8 additions & 0 deletions packages/server/internal/handler/hubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
92 changes: 84 additions & 8 deletions packages/web/src/app/(app)/home/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 12 additions & 2 deletions packages/web/src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 10 additions & 6 deletions packages/web/src/components/bar/expand-search-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -946,9 +954,7 @@ export function ExpandSearchResults() {
{!jumpToRows.length && !visibleRows.length && value.trim() ? (
<div className="px-5 py-4 sm:px-6">
<p className="text-[15px] text-fg-3">{t.bar.noResults}</p>
<p className="mt-1 text-[13px] text-fg-3">
{t.bar.noResultsHint}
</p>
<p className="mt-1 text-[13px] text-fg-3">{noResultsHint}</p>
</div>
) : null}
</>
Expand Down Expand Up @@ -993,9 +999,7 @@ export function ExpandSearchResults() {
!recallFetching ? (
<div className="px-5 py-4 sm:px-6">
<p className="text-[15px] text-fg-3">{t.bar.noResults}</p>
<p className="mt-1 text-[13px] text-fg-3">
{t.bar.noResultsHint}
</p>
<p className="mt-1 text-[13px] text-fg-3">{noResultsHint}</p>
</div>
) : null}
</>
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/components/bar/synthesis-slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ export function SynthesisSlot({
>
</span>
<span className="flex-1">{t.bar.ai.error}</span>
{/* "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. */}
<span className="flex-1">
{sources.length ? t.bar.ai.error : t.bar.ai.errorNoSources}
</span>
<button
type="button"
onClick={onRetry}
Expand Down
35 changes: 22 additions & 13 deletions packages/web/src/components/features/hub/hub-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,28 @@ export function HubHeader({
displayName: viewerDisplayName,
});

const greeting = useMemo(
() =>
interpolate(
t.hubHeader.greeting[summary.header.greeting_key],
summary.header.greeting_params,
),
[
interpolate,
summary.header.greeting_key,
summary.header.greeting_params,
t,
],
);
const greeting = useMemo(() => {
const params = summary.header.greeting_params;
// The server sends the English sentinel "there" (legacy) or ""
// when the account has no display name — either way the fallback
// is a client concern so each locale can pick a word that works
// inside its own greeting templates (the raw server "there"
// leaked English into every locale). The server keeps sending
// "there" for back-compat with pre-fallback bundles; see hubs.go.
const hasRealName = Boolean(params?.name) && params.name !== "there";
const withNameFallback = hasRealName
? params
: { ...params, name: t.hubHeader.greetingNameFallback };
return interpolate(
t.hubHeader.greeting[summary.header.greeting_key],
withNameFallback,
);
}, [
interpolate,
summary.header.greeting_key,
summary.header.greeting_params,
t,
]);

const statsLine = useMemo(
() =>
Expand Down
83 changes: 60 additions & 23 deletions packages/web/src/components/features/hub/hub-identity-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Expand Down Expand Up @@ -124,22 +136,47 @@ export function HubIdentityChip({
className: "max-w-full glass-pill rounded-full",
});

const isRail = variant === "rail";
const trigger = isRail ? (
<PopoverTrigger
aria-label={`${t.hubs.switchTitle}: ${label}`}
className="flex h-9 w-9 items-center justify-center rounded-card cursor-pointer transition-colors hover:bg-surface-2"
>
<HubBadge kind={kind} label={badgeLabel} accent={accent} size="md" />
</PopoverTrigger>
) : (
<PopoverTrigger className={triggerClass}>
<HubBadge
kind={kind}
label={badgeLabel}
accent={accent}
size={isEmbedded ? "md" : "lg"}
/>
<span className="truncate">{label}</span>
{role && <HubRoleTag role={role} />}
<ChevronDown
className={`shrink-0 text-fg-4 ${isEmbedded ? "h-3 w-3" : "h-4 w-4"}`}
/>
</PopoverTrigger>
);

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className={triggerClass}>
<HubBadge
kind={kind}
label={badgeLabel}
accent={accent}
size={isEmbedded ? "md" : "lg"}
/>
<span className="truncate">{label}</span>
{role && <HubRoleTag role={role} />}
<ChevronDown
className={`shrink-0 text-fg-4 ${isEmbedded ? "h-3 w-3" : "h-4 w-4"}`}
/>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" sideOffset={6}>
{isRail ? (
<Tooltip>
<TooltipTrigger render={trigger} />
<TooltipContent side="right" sideOffset={12}>
{label}
</TooltipContent>
</Tooltip>
) : (
trigger
)}
<PopoverContent
side={isRail ? "right" : "bottom"}
align="start"
sideOffset={isRail ? 12 : 6}
>
<div className="w-60 p-1">
<HubSwitcherMenu
onSelect={() => setOpen(false)}
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading