Files
multica/packages/views/layout/app-sidebar.test.tsx
Naiyuan Qing 2f111037d2 feat(desktop): tab presentation by object identity (MUL-4370) (#5661)
* fix(nav): derive route icons from the URL across all nav surfaces (MUL-4370)

The same route rendered different icons in the sidebar and the desktop tab
bar because the mapping was maintained in three places. Projects had no tab
icon at all; autopilots/chat/squads/usage fell back to ListTodo.

Establish one contract instead: `@multica/core/paths` maps a route segment to
a stable icon *name* (React-free), and `@multica/views/layout` maps that name
to a Lucide component. Every nav surface — sidebar, desktop tab bar, and the
search palette — resolves through `routeIconForPath(path)`, so a route cannot
render two different icons.

Crucially the icon is now derived, not stored. `TabSession.icon` is removed,
so persisted tab state can no longer hold a stale icon name: a user who had
an /autopilots tab from an older build gets the correct icon after upgrade
rather than the one that was persisted. Legacy `icon` values in v4 payloads
are ignored on rehydration and dropped on the next write.

Builds on the design in #5204 by LiangliangSui.

Tests: stale/unknown/absent persisted icon on rehydration, derived icon
rendering per route in the tab bar, name→component registry totality, and
nav-route icon coverage.

Co-authored-by: multica-agent <github@multica.ai>

* feat(desktop): tab presentation by object identity, not route segment (MUL-4370)

Replace the "route segment → icon" tab mapping with a semantic Tab
Presentation Contract: a tab's leading visual and title are derived live
from its URL + the query cache, so a tab shows *what it points at*, not the
module it lives under.

- core `parseTabSubject(url)` classifies a URL as page / resource / actor /
  container (inbox, chat) / flow / unknown, purely (no React, no Lucide).
- core `resolveTabPresentation(subject, data)` maps that + cached entity data
  to a leading visual (issue StatusIcon, ProjectIcon, ActorAvatar, or a type
  icon) and a title spec. Exhaustive: a new route forces an explicit choice.
- views `useTabPresentation` reads the cache (enabled:false, no fetch from the
  tab bar) and `ResourceLeadingVisual` renders it in a fixed 16×16 slot.
- Containers keep their icon; only the title tracks the selection
  (`/inbox?issue=`, `/chat?session=`), so an inbox-opened issue reads
  differently from a direct issue tab.
- Titles are plain text; the project 📁 and autopilot  glyphs are dropped
  from document.title.
- Pin no longer replaces the resource visual. Persisted `tab.icon` cleanup
  from the prior revision is kept; the active tab persists its resolved title
  as a first-frame fallback (the document.title→observer path is removed).

Supersedes the route-segment approach in #5204 per the agreed PRD. No schema,
API, or migration changes; reuses existing queries/caches.

Tests: table-driven parseTabSubject over every desktop route; the
URL/data → visual+title matrix incl. pending/loading, containers, unknown,
runtime custom-name; views cache-integration; tab-bar pin + active-tab
title persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): archived inbox title sync + attachment filename in tab (MUL-4370)

Addresses two PRD gaps from review:

1. Archived Inbox selection now syncs the tab title. `parseTabSubject` captures
   `?view=archived` on the inbox subject, and the presentation hook resolves the
   selection against `archivedInboxListOptions` (its own cache, the one the
   InboxPage populates) instead of only the active list. An
   `/inbox?view=archived&issue=<id>` tab now shows the archived item's title —
   issue (`identifier: title`) or non-issue (display title) — and, being purely
   URL+cache derived, restores correctly on refresh. Previously it fell back to
   "Inbox" and persisted that wrong title.

2. Attachment tabs use the filename. `parseTabSubject` captures the `?name=`
   the preview route already carries; the resolver shows the filename as the
   title and picks a file-type icon from its extension (image/video/audio/
   archive/code/text), falling back to the generic File glyph + "Attachment"
   label only when the name is missing.

Tests: parseTabSubject archived/name cases; the presentation matrix for
attachment filename/extension + missing fallback and iconForAttachment edge
cases; views cache-integration for archived issue/non-issue selection (must
resolve against the archived list, not the active one) and attachment filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:36:53 +08:00

400 lines
18 KiB
TypeScript

import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { appForeground, chatSessions, chatStore, detail, deletePin, inboxItems, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
appForeground: { current: true },
chatSessions: { current: [] as { id?: string; unread_count?: number }[] },
chatStore: { current: { activeSessionId: null as string | null, isOpen: false } },
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
inboxItems: { current: [] as { id: string; read: boolean }[] },
navigation: { current: { pathname: "/acme/issues" } },
summary: { current: [] as { workspace_id: string; count: number }[] },
workspaces: {
current: [] as { id: string; name: string; slug: string; avatar_url: string | null }[],
},
pins: {
current: [
{
id: "pin-1",
workspace_id: "ws-1",
user_id: "user-1",
item_type: "issue" as const,
item_id: "issue-1",
position: 0,
created_at: "2026-05-06T00:00:00Z",
},
],
},
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: vi.fn(),
closestCenter: vi.fn(),
useSensor: vi.fn(),
useSensors: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn() }),
verticalListSortingStrategy: vi.fn(),
}));
vi.mock("@dnd-kit/utilities", () => ({ CSS: { Transform: { toString: () => undefined } } }));
vi.mock("@multica/ui/components/ui/sidebar", () => ({
Sidebar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarFooter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarHeader: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenuButton: ({
children,
isActive,
render,
}: {
children: React.ReactNode;
isActive?: boolean;
render?: React.ReactElement<{ href?: string }>;
}) => (
<button type="button" data-active={isActive ? "true" : undefined} data-href={render?.props.href}>
{children}
</button>
),
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarRail: () => null,
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
}));
vi.mock("@multica/ui/components/ui/collapsible", () => ({
Collapsible: ({ children }: { children: React.ReactNode }) => <>{children}</>,
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
CollapsibleTrigger: () => <button type="button" />,
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock("../common/use-app-foreground", () => ({
useAppForeground: () => appForeground.current,
}));
vi.mock("./help-launcher", () => ({ HelpLauncher: () => null }));
vi.mock("../auth", () => ({ useLogout: () => vi.fn() }));
vi.mock("../issues/components/status-icon", () => ({ StatusIcon: () => <span /> }));
vi.mock("../navigation", () => ({
AppLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useNavigation: () => ({ pathname: navigation.current.pathname, push: vi.fn() }),
}));
vi.mock("../projects/components/project-icon", () => ({ ProjectIcon: () => <span /> }));
vi.mock("../workspace/workspace-avatar", () => ({ WorkspaceAvatar: () => <span /> }));
vi.mock("@multica/ui/components/common/actor-avatar", () => ({ ActorAvatar: () => <span /> }));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => selector({ user: { id: "user-1" } }),
}));
// Callable-store shape (selectorFn + getState) per the repo testing rules.
vi.mock("@multica/core/chat", () => ({
useChatStore: Object.assign(
(selector: (state: { activeSessionId: string | null; isOpen: boolean }) => unknown) =>
selector(chatStore.current),
{ getState: () => chatStore.current },
),
}));
vi.mock("@multica/core/paths", async (importOriginal) => ({
// Spread the real module so pure helpers (resolveRouteIconName, used by the
// nav to derive each item's icon from its href) stay intact; only the
// workspace/context hooks below are stubbed to control routes in tests.
...(await importOriginal<typeof import("@multica/core/paths")>()),
paths: { workspace: (slug: string) => ({ issues: () => `/${slug}/issues` }) },
useCurrentWorkspace: () => ({ id: "ws-1", name: "Acme", slug: "acme" }),
useWorkspacePaths: () => ({
inbox: () => "/acme/inbox",
chat: () => "/acme/chat",
myIssues: () => "/acme/my-issues",
issues: () => "/acme/issues",
projects: () => "/acme/projects",
autopilots: () => "/acme/autopilots",
agents: () => "/acme/agents",
squads: () => "/acme/squads",
usage: () => "/acme/usage",
runtimes: () => "/acme/runtimes",
skills: () => "/acme/skills",
settings: () => "/acme/settings",
issueDetail: (id: string) => `/acme/issues/${id}`,
projectDetail: (id: string) => `/acme/projects/${id}`,
}),
}));
vi.mock("@multica/core/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/api")>();
return {
...actual,
api: {
...actual.api,
getBaseUrl: () => "http://127.0.0.1:8080",
},
};
});
vi.mock("@multica/core/inbox/queries", () => ({
deduplicateInboxItems: (items: unknown[]) => items,
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
hasOtherWorkspaceUnread: (
entries: { workspace_id: string; count: number }[],
currentWsId: string | null,
) => entries.some((s) => s.workspace_id !== currentWsId && s.count > 0),
unreadWorkspaceIds: (entries: { workspace_id: string; count: number }[]) =>
new Set(entries.filter((s) => s.count > 0).map((s) => s.workspace_id)),
}));
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
useCreateModeStore: { getState: () => ({ lastMode: "agent" }) },
openCreateIssueWithPreference: vi.fn(),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({ useIssueDraftStore: () => false }));
vi.mock("@multica/core/modals", () => ({ useModalStore: { getState: () => ({ modal: null, open: vi.fn() }) } }));
vi.mock("@multica/core/pins/mutations", () => ({ useDeletePin: () => ({ mutate: deletePin }), useReorderPins: () => ({ mutate: vi.fn() }) }));
vi.mock("@multica/core/pins/queries", () => ({ pinListOptions: () => ({ queryKey: ["pins"] }) }));
vi.mock("@multica/core/projects/queries", () => ({ projectDetailOptions: () => ({ queryKey: ["project"] }) }));
vi.mock("@multica/core/workspace/queries", () => ({
myInvitationListOptions: () => ({ queryKey: ["invitations"] }),
workspaceKeys: { myInvitations: () => ["invitations"] },
workspaceListOptions: () => ({ queryKey: ["workspaces"] }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => ({
...(await importOriginal<typeof import("@tanstack/react-query")>()),
useMutation: () => ({ isPending: false, mutate: vi.fn() }),
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (queryKey[0] === "pins") return { data: pins.current };
if (queryKey[0] === "issue") return detail.current;
if (queryKey[0] === "inbox" && queryKey[1] === "unread-summary") return { data: summary.current };
if (queryKey[0] === "inbox") return { data: inboxItems.current };
if (queryKey[0] === "workspaces") return { data: workspaces.current };
if (queryKey[0] === "chat" && queryKey[2] === "sessions") return { data: chatSessions.current };
return { data: [] };
},
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
}));
describe("PinRow", () => {
beforeEach(() => {
deletePin.mockReset();
navigation.current.pathname = "/acme/issues";
detail.current = { isPending: false, isError: false, data: null, error: null };
summary.current = [];
workspaces.current = [];
});
it("unpins missing details", async () => {
detail.current = { isPending: false, isError: true, data: null, error: new ApiError("missing", 404, "Not Found") };
render(<AppSidebar />);
await waitFor(() => expect(deletePin).toHaveBeenCalledTimes(1));
});
it("ignores non-404 errors", async () => {
detail.current = { isPending: false, isError: true, data: null, error: new ApiError("error", 500, "Server Error") };
render(<AppSidebar />);
await waitFor(() => expect(deletePin).not.toHaveBeenCalled());
});
it("renders loaded details", async () => {
detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null };
render(<AppSidebar />);
expect(await screen.findByText("Keep this pin")).toBeInTheDocument();
expect(screen.queryByText("MUL-123 Keep this pin")).not.toBeInTheDocument();
});
it("does not also highlight the parent workspace nav for an active pin", async () => {
navigation.current.pathname = "/acme/issues/issue-1";
detail.current = {
isPending: false,
isError: false,
data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" },
error: null,
};
const { container } = render(<AppSidebar />);
expect((await screen.findByText("Keep this pin")).closest("button")).toHaveAttribute(
"data-active",
"true",
);
expect(container.querySelector('button[data-href="/acme/issues"]')).not.toHaveAttribute("data-active");
});
});
describe("workspace-switcher unread dot", () => {
beforeEach(() => {
summary.current = [];
workspaces.current = [];
});
// The aggregate switcher dot is the only `.ring-sidebar` span in the tree
// (DraftDot is null when there's no draft, and there are no invitations).
const dot = (container: HTMLElement) => container.querySelector("span.bg-brand.ring-sidebar");
it("shows a dot when another workspace has unread inbox items", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).not.toBeNull();
});
it("does not show a dot when only the active workspace has unread", () => {
// Active workspace is ws-1 (see useCurrentWorkspace mock).
summary.current = [{ workspace_id: "ws-1", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
it("does not show a dot when no workspace has unread", () => {
summary.current = [];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
});
describe("workspace-switcher dropdown per-workspace dot", () => {
beforeEach(() => {
summary.current = [];
// Active workspace is ws-1 (see useCurrentWorkspace mock); "Other" is ws-2.
workspaces.current = [
{ id: "ws-1", name: "Active WS", slug: "active", avatar_url: null },
{ id: "ws-2", name: "Other WS", slug: "other", avatar_url: null },
];
});
// Row dots are brand dots WITHOUT the aggregate avatar dot's `ring-sidebar`.
const rowDots = (container: HTMLElement) =>
container.querySelectorAll("span.bg-brand:not(.ring-sidebar)");
it("dots the specific other workspace that has unread", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
// Exactly one row dot, sitting right after the "Other WS" name; the active
// row shows the check, not a dot.
expect(rowDots(container)).toHaveLength(1);
expect(screen.getByText("Other WS").nextElementSibling?.className).toContain("bg-brand");
expect(screen.getByText("Active WS").nextElementSibling?.className ?? "").not.toContain("bg-brand");
});
it("does not dot a workspace whose unread count is zero", () => {
summary.current = [{ workspace_id: "ws-2", count: 0 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
it("never dots the active workspace even when it has unread", () => {
summary.current = [{ workspace_id: "ws-1", count: 5 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
});
describe("personal nav — Chat", () => {
beforeEach(() => {
chatSessions.current = [];
inboxItems.current = [];
navigation.current = { pathname: "/acme/issues" };
chatStore.current = { activeSessionId: null, isOpen: false };
appForeground.current = true;
});
// The mocked SidebarMenuButton exposes the AppLink target as `data-href`
// and renders the label + badge as its children.
const chatNav = (container: HTMLElement) =>
container.querySelector<HTMLElement>('button[data-href="/acme/chat"]');
const chatBadge = (container: HTMLElement) =>
chatNav(container)?.querySelector("number-flow-react") ?? null;
it("keeps persistent Inbox and Chat counters static", () => {
inboxItems.current = [{ id: "inbox-1", read: false }];
chatSessions.current = [{ id: "chat-1", unread_count: 2 }];
const { container } = render(<AppSidebar />);
const inboxBadge = container
.querySelector<HTMLElement>('button[data-href="/acme/inbox"]')
?.querySelector("number-flow-react") as (HTMLElement & { animated?: boolean }) | null;
const currentChatBadge = chatBadge(container) as (HTMLElement & { animated?: boolean }) | null;
expect(inboxBadge?.animated).toBe(false);
expect(currentChatBadge?.animated).toBe(false);
});
it("renders a Chat nav link to the workspace chat route", () => {
const { container } = render(<AppSidebar />);
expect(chatNav(container)).not.toBeNull();
});
it("badges the Chat nav with the summed unread_count of chat sessions", () => {
chatSessions.current = [{ id: "a", unread_count: 3 }, { id: "b", unread_count: 2 }, { id: "c", unread_count: 0 }];
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "5");
});
it("shows no Chat unread badge when every session is read", () => {
chatSessions.current = [{ id: "a", unread_count: 0 }, { id: "b" }];
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toBeNull();
});
it("excludes the session being viewed on the chat page from the badge", () => {
// The thread list zeroes the open session's row badge; the aggregate
// must follow, or a reply landing in the open conversation flashes a
// count with no matching row.
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
navigation.current = { pathname: "/acme/chat" };
chatStore.current = { activeSessionId: "a", isOpen: false };
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "3");
});
it("excludes the viewed session when the floating chat window is open off-route", () => {
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
navigation.current = { pathname: "/acme/issues" };
chatStore.current = { activeSessionId: "a", isOpen: true };
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "3");
});
it("still counts a remembered selection when no chat surface is showing it", () => {
// activeSessionId persists after the chat page closes; with both
// surfaces closed nothing will auto mark-read, so the badge must count.
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
navigation.current = { pathname: "/acme/issues" };
chatStore.current = { activeSessionId: "a", isOpen: false };
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "5");
});
it("counts the active session while the floating window is open but the app is backgrounded", () => {
// A reply landing while the app is not in the foreground is NOT auto
// marked-read (MUL-4485), so its unread must still badge — otherwise the
// notification is silently eaten while the user is away.
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
navigation.current = { pathname: "/acme/issues" };
chatStore.current = { activeSessionId: "a", isOpen: true };
appForeground.current = false;
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "5");
});
it("counts the active session on the chat route while the app is backgrounded", () => {
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
navigation.current = { pathname: "/acme/chat" };
chatStore.current = { activeSessionId: "a", isOpen: false };
appForeground.current = false;
const { container } = render(<AppSidebar />);
expect(chatBadge(container)).toHaveAttribute("aria-label", "5");
});
});