diff --git a/CONTEXT.md b/CONTEXT.md
index 94c4f5a4..c58b7aa8 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -33,5 +33,9 @@ An accepted relationship between an active Memos user and a Space, carrying eith
_Avoid_: Invitation, application role
**Memo collection scope**:
-The Space-placement dimension applied to memo and derived-resource collections. It is either `all`, which adds no Space predicate, or one exact Space. An unassigned Memo remains part of `all`; unassigned is a placement, not a collection scope.
+The Space-placement dimension applied to Space-aware memo and derived-resource collections. It is either `all`, which adds no Space predicate, or one exact Space. An unassigned Memo remains part of `all`; unassigned is a placement, not a collection scope.
_Avoid_: Unassigned scope, no-Space collection
+
+**Archived memo collection**:
+The signed-in user's memos in the archived lifecycle state across all placements. It is user-level and independent of the current Space collection scope.
+_Avoid_: Space archive, Space-scoped archive
diff --git a/docs/design/multi-spaces.md b/docs/design/multi-spaces.md
index bf6bffb1..399e25af 100644
--- a/docs/design/multi-spaces.md
+++ b/docs/design/multi-spaces.md
@@ -170,7 +170,7 @@ MCP memo operations reuse the same memo policy; Space management is not exposed
### UI shape
-The active Space scopes collaborative resource browsing and creation, including Home and attachment lists. Inbox and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
+The active Space scopes collaborative resource browsing and creation, including Home, Explore, and attachment lists. Archived, Inbox, and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
### Security invariants
diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx
index 0b592ba2..d72f61e0 100644
--- a/web/src/components/AppSidebar/AppSidebar.tsx
+++ b/web/src/components/AppSidebar/AppSidebar.tsx
@@ -55,7 +55,7 @@ import {
getMemoScopePath,
getMemoViewId,
isMemoScopeRoute,
- type MemoScope,
+ type PrimaryMemoScope,
resolveMemoScope,
} from "@/lib/memo-views";
import { cn } from "@/lib/utils";
@@ -257,8 +257,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
enabled: context === "profile" && !!profileMatch?.params.username,
});
const statsUserName = context === "home" ? currentUser?.name : context === "profile" ? profileUser?.name : undefined;
- // Profile remains instance-level, so its calendar must stay aligned with the unscoped profile feed.
- const statsFilter = context === "profile" ? undefined : memoFilter;
+ // User-level collections stay aligned with their unscoped feeds even when a Space is remembered.
+ const isUserLevelCollection = context === "profile" || context === "archived";
+ const statsFilter = isUserLevelCollection ? undefined : memoFilter;
const { statistics, tags } = useFilteredMemoStats({
context,
userName: statsUserName,
@@ -272,8 +273,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
// clicks must land somewhere that renders the filtered feed.
const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch;
const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME;
- const tagStateScope =
- context === "profile" ? (statsUserName ?? context) : `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`;
+ const tagStateScope = isUserLevelCollection
+ ? (statsUserName ?? context)
+ : `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`;
return (
@@ -484,24 +486,25 @@ const GlobalNavigation = () => {
memoArchived: memoDetail?.memo.state === State.ARCHIVED,
fallback: memoScope,
});
- const routeOwnsScope = isMemoScopeRoute(location.pathname) || routeKind === "profile" || routeKind === "memo";
- const scopeRouteActive = isMemoScopeRoute(location.pathname);
+ const primaryScope: PrimaryMemoScope = resolvedScope === "archived" ? memoScope : resolvedScope;
+ const routeOwnsPrimaryScope =
+ resolvedScope !== "archived" && (routeKind === "home" || routeKind === "explore" || routeKind === "profile" || routeKind === "memo");
+ const scopeRouteActive = routeKind === "home" || routeKind === "explore";
useEffect(() => {
- if (routeOwnsScope && resolvedScope !== memoScope) {
- setMemoScope(resolvedScope);
+ if (routeOwnsPrimaryScope && primaryScope !== memoScope) {
+ setMemoScope(primaryScope);
}
- }, [memoScope, resolvedScope, routeOwnsScope, setMemoScope]);
+ }, [memoScope, primaryScope, routeOwnsPrimaryScope, setMemoScope]);
- const scopeItems: Array<{ id: MemoScope; label: string; icon: LucideIcon }> = [
+ const scopeItems: Array<{ id: PrimaryMemoScope; label: string; icon: LucideIcon }> = [
{ id: "home", label: t("common.home"), icon: HouseIcon },
{ id: "explore", label: t("common.explore"), icon: EarthIcon },
- { id: "archived", label: t("common.archived"), icon: ArchiveIcon },
];
- const activeScopeItem = scopeItems.find((item) => item.id === resolvedScope) ?? scopeItems[0];
+ const activeScopeItem = scopeItems.find((item) => item.id === primaryScope) ?? scopeItems[0];
const ActiveScopeIcon = activeScopeItem.icon;
- const navigateToScope = (scope: MemoScope) => {
+ const navigateToScope = (scope: PrimaryMemoScope) => {
const filterQuery = stringifyFilters(filters);
setMemoScope(scope);
navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" });
@@ -568,7 +571,7 @@ const GlobalNavigation = () => {
// menu and navigate to the scope instead.
if (open && !scopeRouteActive) {
eventDetails.cancel();
- navigateToScope(resolvedScope);
+ navigateToScope(primaryScope);
}
}}
>
diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts
index 06cc798f..40b3a147 100644
--- a/web/src/components/AppSidebar/routes.ts
+++ b/web/src/components/AppSidebar/routes.ts
@@ -4,7 +4,7 @@ import { ROUTES } from "@/router/routes";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "empty";
-export type RouteSearchScope = "remembered-collection" | "profile" | "all";
+export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all";
export type RouteComposePlacement = "remembered-space" | "unassigned";
export interface RouteActionPolicy {
@@ -29,7 +29,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
/** Routes whose collections are filtered by the remembered All / Space scope. */
export const routeSupportsCollectionScope = (path: string): boolean => {
const kind = getSidebarRouteKind(path);
- return kind === "home" || kind === "explore" || kind === "archived" || kind === "attachments";
+ return kind === "home" || kind === "explore" || kind === "attachments";
};
/**
@@ -40,13 +40,20 @@ export const routeSupportsCollectionScope = (path: string): boolean => {
export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
const kind = getSidebarRouteKind(path);
- if (kind === "home" || kind === "explore" || kind === "archived") {
+ if (kind === "home" || kind === "explore") {
return {
searchScope: "remembered-collection",
composePlacement: "remembered-space",
};
}
+ if (kind === "archived") {
+ return {
+ searchScope: "user-collection",
+ composePlacement: "unassigned",
+ };
+ }
+
if (kind === "attachments") {
return {
searchScope: "remembered-collection",
diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts
index ef68e5c7..8652ca67 100644
--- a/web/src/components/MemoActionMenu/hooks.ts
+++ b/web/src/components/MemoActionMenu/hooks.ts
@@ -105,13 +105,10 @@ export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDial
}
if (isInMemoDetailPage) {
- if (parentScope === "all") {
- clearSelectedSpace();
- }
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
}
memoUpdatedCallback();
- }, [memo.name, memo.state, t, isInMemoDetailPage, parentScope, clearSelectedSpace, navigateTo, memoUpdatedCallback, updateMemo]);
+ }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => {
let host = profile.instanceUrl;
diff --git a/web/src/components/MemoView/navigation.ts b/web/src/components/MemoView/navigation.ts
index ca1c1732..94ccf3c4 100644
--- a/web/src/components/MemoView/navigation.ts
+++ b/web/src/components/MemoView/navigation.ts
@@ -40,7 +40,7 @@ export const isMemoResourcePath = (pathname: string): boolean => {
return memoID.length > 0 && !memoID.includes("/");
};
-/** Whether a page consumes the remembered All / exact-Space collection scope. */
+/** Whether returning from this collection should preserve the remembered All / Space state. */
export const isMemoCollectionOrigin = (page: string): boolean => {
const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS;
@@ -62,7 +62,9 @@ export const resolveMemoDetailOrigin = (
? value.fromScope
: hasExplicitParent && isMemoCollectionOrigin(parentPage)
? "preserve"
- : "all";
+ : options.memoArchived
+ ? "preserve"
+ : "all";
return { parentPage, parentScope };
};
diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx
index 81d19107..4e4c29a3 100644
--- a/web/src/components/UserMenu.tsx
+++ b/web/src/components/UserMenu.tsx
@@ -1,4 +1,5 @@
import {
+ ArchiveIcon,
CheckIcon,
ChevronsUpDownIcon,
GlobeIcon,
@@ -9,6 +10,7 @@ import {
SquareUserIcon,
User2Icon,
} from "lucide-react";
+import { matchPath, useLocation } from "react-router-dom";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@@ -25,6 +27,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -39,6 +42,7 @@ interface Props {
const UserMenu = (props: Props) => {
const { collapsed } = props;
const t = useTranslate();
+ const location = useLocation();
const navigateTo = useNavigateTo();
const { setMobileOpen } = useAppSidebar();
const currentUser = useCurrentUser();
@@ -47,6 +51,7 @@ const UserMenu = (props: Props) => {
const sseStatus = useSSEConnectionStatus();
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
+ const archivedActive = Boolean(matchPath(Routes.ARCHIVED, location.pathname));
const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
@@ -154,6 +159,15 @@ const UserMenu = (props: Props) => {
{t("common.profile")}
+
navigateFromMenu(Routes.ARCHIVED)}
+ >
+
+ {t("common.archived")}
+
+
@@ -186,6 +200,7 @@ const UserMenu = (props: Props) => {
{t("common.settings")}
+
{t("common.sign-out")}
diff --git a/web/src/contexts/AppSidebarContext.tsx b/web/src/contexts/AppSidebarContext.tsx
index c62d7d3b..80fca6f9 100644
--- a/web/src/contexts/AppSidebarContext.tsx
+++ b/web/src/contexts/AppSidebarContext.tsx
@@ -1,7 +1,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import type { MemoOriginScope } from "@/components/MemoView/navigation";
-import type { MemoScope } from "@/lib/memo-views";
+import type { PrimaryMemoScope } from "@/lib/memo-views";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export type AttachmentSection = "all" | "media" | "audio" | "documents" | "unused";
@@ -26,8 +26,8 @@ interface AppSidebarContextValue {
setMobileOpen: (open: boolean) => void;
quickFindOpen: boolean;
setQuickFindOpen: (open: boolean) => void;
- memoScope: MemoScope;
- setMemoScope: (scope: MemoScope) => void;
+ memoScope: PrimaryMemoScope;
+ setMemoScope: (scope: PrimaryMemoScope) => void;
}
const AppSidebarContext = createContext(null);
@@ -39,7 +39,7 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
const [memoDetail, setMemoDetailState] = useState();
const [mobileOpen, setMobileOpen] = useState(false);
const [quickFindOpen, setQuickFindOpen] = useState(false);
- const [memoScope, setMemoScope] = useState("home");
+ const [memoScope, setMemoScope] = useState("home");
useEffect(() => {
setMobileOpen(false);
diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts
index 742ab4bb..65309387 100644
--- a/web/src/lib/memo-views.ts
+++ b/web/src/lib/memo-views.ts
@@ -1,6 +1,7 @@
import { ROUTES } from "@/router/routes";
export type MemoScope = "home" | "explore" | "archived";
+export type PrimaryMemoScope = Exclude;
export const BUILTIN_TASKS_VIEW_ID = "__built_in_tasks__";
export const BUILTIN_TASKS_VIEW_FILTER = "has_task_list && has_incomplete_tasks";
@@ -20,9 +21,8 @@ export const isMemoScopeRoute = (pathname: string): boolean => {
return comparablePath === ROUTES.HOME || comparablePath === ROUTES.EXPLORE || comparablePath === ROUTES.ARCHIVED;
};
-export const getMemoScopePath = (scope: MemoScope): string => {
+export const getMemoScopePath = (scope: PrimaryMemoScope): string => {
if (scope === "explore") return ROUTES.EXPLORE;
- if (scope === "archived") return ROUTES.ARCHIVED;
return ROUTES.HOME;
};
diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx
index 014aa52c..f0a1cb70 100644
--- a/web/src/pages/Archived.tsx
+++ b/web/src/pages/Archived.tsx
@@ -1,14 +1,16 @@
+import { ArchiveIcon } from "lucide-react";
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
-import { useSpaceContext } from "@/contexts/SpaceContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
+import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import { useTranslate } from "@/utils/i18n";
const Archived = () => {
const user = useCurrentUser();
- const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
+ const t = useTranslate();
const memoFilter = useMemoFilters({
creatorName: user?.name,
@@ -24,14 +26,17 @@ const Archived = () => {
return (
(
-
- )}
+ renderer={(memo: Memo, { compact }) => }
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
filter={memoFilter}
- contextFilter={contextFilter}
+ renderLeading={({ useGrid }) => (
+
+
+ {t("common.archived")}
+
+ )}
/>
);
};
diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx
index 250c0253..6bfbecf2 100644
--- a/web/tests/app-sidebar-logo.test.tsx
+++ b/web/tests/app-sidebar-logo.test.tsx
@@ -11,7 +11,7 @@ const authState = vi.hoisted(() => ({
notifications: [] as Array<{ status: number }>,
}));
const sidebarState = vi.hoisted(() => ({
- memoScope: "home" as "home" | "explore" | "archived",
+ memoScope: "home" as "home" | "explore",
mobileOpen: false,
}));
const globalEditorState = vi.hoisted(() => ({
@@ -193,7 +193,6 @@ describe("App sidebar logo", () => {
it.each([
"/",
"/explore",
- "/archived",
"/attachments",
"/Explore/",
"/Attachments/",
@@ -214,6 +213,7 @@ describe("App sidebar logo", () => {
});
it.each([
+ "/archived",
"/inbox",
"/u/alice",
"/setting",
@@ -276,6 +276,20 @@ describe("App sidebar logo", () => {
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" }));
});
+ it("keeps Archived statistics and tag UI state independent of the remembered Space", () => {
+ spaceState.selectedSpaceName = "spaces/product";
+ spaceState.memoFilter = 'space == "spaces/product"';
+
+ render(
+
+
+ ,
+ );
+
+ expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "archived", filter: undefined }));
+ expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" }));
+ });
+
it("keeps Inbox compact while exposing its unread state accessibly", () => {
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
@@ -423,7 +437,7 @@ describe("App sidebar logo", () => {
fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.explore" })).toBeInTheDocument();
- expect(screen.getByRole("menuitem", { name: "common.archived" })).toBeInTheDocument();
+ expect(screen.queryByRole("menuitem", { name: "common.archived" })).not.toBeInTheDocument();
});
it("uses compact text-only actions for a saved view", async () => {
@@ -484,22 +498,34 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("heading", { name: label, level: 2 })).toBeInTheDocument();
});
- it.each([
- ["explore", "common.explore", "/inbox"],
- ["archived", "common.archived", "/attachments"],
- ] as const)("keeps the %s scope available from a global destination", async (scope, label, destination) => {
- sidebarState.memoScope = scope;
+ it("keeps the Explore scope available from a global destination", async () => {
+ sidebarState.memoScope = "explore";
render(
-
+
,
);
- const scopeTrigger = screen.getByRole("button", { name: label });
- expectCollapsedNavPill(scopeTrigger, label);
+ const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
+ expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger);
- expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
+ expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
+ });
+
+ it("leaves Archived through the remembered primary feed without presenting it as a scope", async () => {
+ sidebarState.memoScope = "explore";
+ render(
+
+
+ ,
+ );
+
+ const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
+ expectCollapsedNavPill(scopeTrigger, "common.explore");
+
+ fireEvent.click(scopeTrigger);
+ expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
});
it("keeps the mobile header limited to navigation and context", () => {
diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts
index 204f1e49..5d75df9e 100644
--- a/web/tests/app-sidebar-routes.test.ts
+++ b/web/tests/app-sidebar-routes.test.ts
@@ -31,10 +31,10 @@ describe("sidebar route content", () => {
it.each([
["/", true],
["/explore", true],
- ["/archived", true],
+ ["/archived", false],
["/attachments", true],
["/Explore/", true],
- ["/ARCHIVED/", true],
+ ["/ARCHIVED/", false],
["/Attachments/", true],
["/u/steven", false],
["/inbox", false],
@@ -48,13 +48,20 @@ describe("sidebar route content", () => {
expect(routeSupportsCollectionScope(path)).toBe(expected);
});
- it.each(["/", "/explore", "/archived"])("keeps search and Compose in the remembered collection on %s", (path) => {
+ it.each(["/", "/explore"])("keeps search and Compose in the remembered collection on %s", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection",
composePlacement: "remembered-space",
});
});
+ it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive without inheriting Space placement", (path) => {
+ expect(getRouteActionPolicy(path)).toEqual({
+ searchScope: "user-collection",
+ composePlacement: "unassigned",
+ });
+ });
+
it("keeps the remembered scope when Attachments sends search to Home", () => {
expect(getRouteActionPolicy("/attachments")).toEqual({
searchScope: "remembered-collection",
@@ -79,7 +86,8 @@ describe("sidebar route content", () => {
});
});
- it.each(["/Explore/", "/ARCHIVED/"])("keeps normalized collection route %s in the remembered scope", (path) => {
+ it("keeps a normalized Explore route in the remembered scope", () => {
+ const path = "/Explore/";
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection",
composePlacement: "remembered-space",
diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx
index e69b728d..ac6251ae 100644
--- a/web/tests/global-memo-editor.test.tsx
+++ b/web/tests/global-memo-editor.test.tsx
@@ -284,7 +284,7 @@ describe("GlobalMemoEditorProvider", () => {
});
});
- it.each(["/explore", "/archived", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
+ it.each(["/explore", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
mocks.pathname = pathname;
mocks.selectedSpaceName = "spaces/product";
await openViaTrigger();
@@ -297,6 +297,7 @@ describe("GlobalMemoEditorProvider", () => {
it.each([
"/u/steven",
+ "/archived",
"/inbox",
"/setting",
"/views",
diff --git a/web/tests/memo-action-navigation.test.tsx b/web/tests/memo-action-navigation.test.tsx
index 0973e58b..52e3e1ba 100644
--- a/web/tests/memo-action-navigation.test.tsx
+++ b/web/tests/memo-action-navigation.test.tsx
@@ -80,7 +80,7 @@ describe("Memo detail mutation navigation", () => {
it.each([
[State.NORMAL, "/archived"],
[State.ARCHIVED, "/"],
- ])("switches an All-origin %s memo to the global destination", async (state, destination) => {
+ ])("switches an All-origin %s memo to the user-level destination without changing the remembered Space", async (state, destination) => {
const { result } = renderActions(state, "all");
await act(async () => {
@@ -88,7 +88,7 @@ describe("Memo detail mutation navigation", () => {
});
await waitFor(() => expect(result.current.pathname).toBe(destination));
- expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce();
+ expect(mocks.clearSelectedSpace).not.toHaveBeenCalled();
});
it("preserves an exact-Space origin when archiving", async () => {
diff --git a/web/tests/memo-view-navigation.test.ts b/web/tests/memo-view-navigation.test.ts
index a3042588..c574ffc2 100644
--- a/web/tests/memo-view-navigation.test.ts
+++ b/web/tests/memo-view-navigation.test.ts
@@ -51,8 +51,8 @@ describe("memo view navigation", () => {
expect(resolveMemoDetailOrigin({ unrelated: true })).toEqual({ parentPage: "/", parentScope: "all" });
});
- it("uses an All Archived origin for a direct archived memo", () => {
- expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "all" });
+ it("uses a user-level Archived origin without changing the remembered Space", () => {
+ expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "preserve" });
});
it("keeps an explicit origin ahead of the archived fallback", () => {
diff --git a/web/tests/memo-views.test.ts b/web/tests/memo-views.test.ts
index 11b24bc2..17eb37f3 100644
--- a/web/tests/memo-views.test.ts
+++ b/web/tests/memo-views.test.ts
@@ -21,14 +21,13 @@ describe("memo scopes", () => {
expect(resolveMemoScope("/settings", { fallback: "explore" })).toBe("explore");
});
- it("maps only the three collection routes to memo scopes", () => {
+ it("maps collection routes while limiting primary scope paths to Home and Explore", () => {
expect(isMemoScopeRoute("/")).toBe(true);
expect(isMemoScopeRoute("/explore")).toBe(true);
expect(isMemoScopeRoute("/archived")).toBe(true);
expect(isMemoScopeRoute("/attachments")).toBe(false);
expect(getMemoScopePath("home")).toBe("/");
expect(getMemoScopePath("explore")).toBe("/explore");
- expect(getMemoScopePath("archived")).toBe("/archived");
});
});
diff --git a/web/tests/quick-find.test.ts b/web/tests/quick-find.test.ts
index 60035bee..f0bdca70 100644
--- a/web/tests/quick-find.test.ts
+++ b/web/tests/quick-find.test.ts
@@ -22,7 +22,7 @@ describe("Quick Find", () => {
expect(buildQuickFindFilters("project", scopedFilters, false)).toEqual([{ factor: "contentSearch", value: "project" }]);
});
- it.each(["/", "/explore", "/archived"])("keeps scoped filters and stays on %s", (pathname) => {
+ it.each(["/", "/explore"])("keeps scoped filters and stays on %s", (pathname) => {
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
@@ -34,6 +34,18 @@ describe("Quick Find", () => {
});
});
+ it("searches Archived as a user collection without clearing the remembered Space", () => {
+ expect(resolveQuickFindSubmission("/archived", "project", scopedFilters)).toEqual({
+ filters: [
+ { factor: "tagSearch", value: "work" },
+ { factor: "displayTime", value: "2026-08-03" },
+ { factor: "contentSearch", value: "project" },
+ ],
+ destination: undefined,
+ switchToAll: false,
+ });
+ });
+
it("keeps the remembered collection filters when searching from Attachments", () => {
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({
filters: [
diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx
index 50f36d80..20fb5d3b 100644
--- a/web/tests/space-context.test.tsx
+++ b/web/tests/space-context.test.tsx
@@ -130,12 +130,7 @@ describe("SpaceProvider", () => {
expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
});
- it.each([
- "/",
- "/explore",
- "/archived?filter=tagSearch%3Awork",
- "/attachments",
- ])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
+ it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
renderProvider(initialPath);
@@ -146,12 +141,7 @@ describe("SpaceProvider", () => {
expect(screen.getByTestId("path").textContent).toBe(initialPath);
});
- it.each([
- "/",
- "/explore",
- "/archived?filter=tagSearch%3Awork",
- "/attachments",
- ])("preserves the current collection route when switching to All from %s", (initialPath) => {
+ it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to All from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
@@ -165,7 +155,9 @@ describe("SpaceProvider", () => {
it.each([
["Select first Space", "/inbox"],
+ ["Select first Space", "/archived?filter=tagSearch%3Awork"],
["Select Memos", "/u/alice"],
+ ["Select Memos", "/archived?filter=tagSearch%3Awork"],
])("falls back to Home when using %s outside a collection route", (action, initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
diff --git a/web/tests/space-feed-pages.test.tsx b/web/tests/space-feed-pages.test.tsx
index 0d50aacb..e030aa19 100644
--- a/web/tests/space-feed-pages.test.tsx
+++ b/web/tests/space-feed-pages.test.tsx
@@ -1,4 +1,4 @@
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Archived from "@/pages/Archived";
import Explore from "@/pages/Explore";
@@ -22,10 +22,16 @@ vi.mock("@/components/MemoView", () => ({
vi.mock("@/components/PagedMemoList", () => ({
default: (props: Record) => {
state.listProps.push(props);
+ const renderLeading = props.renderLeading as ((options: { useGrid: boolean }) => React.ReactNode) | undefined;
const renderer = props.renderer as
| ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode)
| undefined;
- return {renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}
;
+ return (
+
+ {renderLeading?.({ useGrid: false })}
+ {renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}
+
+ );
},
getMemoKey: (memo: { name: string }) => memo.name,
}));
@@ -46,7 +52,7 @@ vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test" }),
}));
-describe("Space-scoped feed pages", () => {
+describe("Memo feed collection scope", () => {
beforeEach(() => {
state.selectedSpaceName = undefined;
state.memoFilter = undefined;
@@ -55,7 +61,7 @@ describe("Space-scoped feed pages", () => {
state.filterOptions = [];
});
- it("uses the All collection without a Space filter for Explore and Archived", () => {
+ it("uses the All collection without a Space filter for Explore and the user archive", () => {
render(
<>
@@ -65,11 +71,12 @@ describe("Space-scoped feed pages", () => {
expect(state.listProps).toHaveLength(2);
expect(state.listProps[0]).toMatchObject({ contextFilter: undefined });
- expect(state.listProps[1]).toMatchObject({ contextFilter: undefined });
+ expect(state.listProps[1]).not.toHaveProperty("contextFilter");
expect(state.filterOptions[0]).toMatchObject({
visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
});
expect(state.memoViewProps).toEqual([expect.objectContaining({ showSpace: true }), expect.objectContaining({ showSpace: true })]);
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Archived");
});
it("uses the selected Space filter and includes its member audience in Explore", () => {
@@ -83,4 +90,13 @@ describe("Space-scoped feed pages", () => {
});
expect(state.memoViewProps[0]).toMatchObject({ showSpace: false });
});
+
+ it("keeps Archived independent of a remembered Space", () => {
+ state.selectedSpaceName = "spaces/product";
+ state.memoFilter = 'space == "spaces/product"';
+ render();
+
+ expect(state.listProps[0]).not.toHaveProperty("contextFilter");
+ expect(state.memoViewProps[0]).toMatchObject({ showSpace: true });
+ });
});
diff --git a/web/tests/user-menu.test.tsx b/web/tests/user-menu.test.tsx
new file mode 100644
index 00000000..eae24d1d
--- /dev/null
+++ b/web/tests/user-menu.test.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import UserMenu from "@/components/UserMenu";
+
+const mocks = vi.hoisted(() => ({
+ navigateTo: vi.fn(),
+ setMobileOpen: vi.fn(),
+ logout: vi.fn(),
+}));
+
+vi.mock("@/contexts/AppSidebarContext", () => ({
+ useAppSidebar: () => ({ setMobileOpen: mocks.setMobileOpen }),
+}));
+
+vi.mock("@/contexts/AuthContext", () => ({
+ useAuth: () => ({
+ userGeneralSetting: undefined,
+ refetchSettings: vi.fn(),
+ logout: mocks.logout,
+ }),
+}));
+
+vi.mock("@/hooks/useCurrentUser", () => ({
+ default: () => ({ name: "users/steven", username: "steven", displayName: "Steven" }),
+}));
+
+vi.mock("@/hooks/useLiveMemoRefresh", () => ({
+ useSSEConnectionStatus: () => "connected",
+}));
+
+vi.mock("@/hooks/useNavigateTo", () => ({
+ default: () => mocks.navigateTo,
+}));
+
+vi.mock("@/hooks/useUserQueries", () => ({
+ useUpdateUserGeneralSetting: () => ({ mutate: vi.fn() }),
+}));
+
+vi.mock("@/utils/i18n", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getLocaleWithFallback: () => "en",
+ loadLocale: vi.fn(),
+ useTranslate: () => (key: string) => key,
+ };
+});
+
+describe("User menu", () => {
+ beforeEach(() => {
+ mocks.navigateTo.mockReset();
+ mocks.setMobileOpen.mockReset();
+ mocks.logout.mockReset();
+ });
+
+ it("groups Archived with Profile and marks it active", async () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Steven/ }));
+
+ const profile = await screen.findByRole("menuitem", { name: "common.profile" });
+ const archived = screen.getByRole("menuitem", { name: "common.archived" });
+ expect(profile.compareDocumentPosition(archived) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(archived).toHaveAttribute("aria-current", "page");
+ expect(screen.getAllByRole("separator")).toHaveLength(2);
+
+ fireEvent.click(archived);
+ expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
+ expect(mocks.navigateTo).toHaveBeenCalledWith("/archived");
+ });
+});