mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
400 lines
18 KiB
TypeScript
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");
|
|
});
|
|
});
|