Files
multica/packages/views/layout/tab-presentation.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

262 lines
9.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactNode } from "react";
import { render, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { chatSessionsOptions } from "@multica/core/chat/queries";
import {
inboxListOptions,
archivedInboxListOptions,
} from "@multica/core/inbox/queries";
import { agentListOptions } from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
// Mutable workspace stub so a test can simulate "workspace not resolved yet".
const ws = vi.hoisted(() => ({ current: { id: "ws1", slug: "acme" } as { id: string; slug: string } | null }));
vi.mock("@multica/core/paths", async (importOriginal) => ({
...(await importOriginal<typeof import("@multica/core/paths")>()),
useCurrentWorkspace: () => ws.current,
}));
vi.mock("../i18n", async () => {
const layout = (await import("../locales/en/layout.json")).default;
const chat = (await import("../locales/en/chat.json")).default;
const bundles: Record<string, unknown> = { layout, chat };
return {
useT: (ns: string) => ({
t: (select: (b: Record<string, unknown>) => string) =>
select(bundles[ns] as Record<string, unknown>),
}),
};
});
// ActorAvatar reaches into workspace directory queries; the hook returns a
// descriptor (not the rendered avatar), so the render test stubs it.
vi.mock("../common/actor-avatar", () => ({
ActorAvatar: ({ actorType, actorId }: { actorType: string; actorId: string }) => (
<span data-testid="actor-avatar" data-actor={`${actorType}:${actorId}`} />
),
}));
import { useTabPresentation, ResourceLeadingVisual } from "./tab-presentation";
function makeClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function seed(qc: QueryClient) {
qc.setQueryData(issueDetailOptions("ws1", "i1").queryKey, {
id: "i1",
identifier: "MUL-1",
title: "Fix login",
status: "in_progress",
} as never);
qc.setQueryData(issueDetailOptions("ws1", "i9").queryKey, {
id: "i9",
identifier: "MUL-9",
title: "Crash",
status: "todo",
} as never);
qc.setQueryData(projectDetailOptions("ws1", "p1").queryKey, {
id: "p1",
icon: "🚀",
title: "Apollo",
} as never);
qc.setQueryData(chatSessionsOptions("ws1").queryKey, [
{ id: "s1", title: "Deploy plan", status: "active" },
{ id: "s2", title: " ", status: "active" },
] as never);
qc.setQueryData(inboxListOptions("ws1").queryKey, [
{ id: "n1", issue_id: "i9", title: "Assigned to you", type: "issue_assigned" },
{ id: "n2", issue_id: null, title: "Quick create failed", type: "quick_create_failed" },
] as never);
// Archived list is a distinct cache; these items are NOT in the main list.
qc.setQueryData(archivedInboxListOptions("ws1").queryKey, [
{ id: "a1", issue_id: "i1", title: "Old assignment", type: "issue_assigned" },
{ id: "a2", issue_id: null, title: "Archived note", type: "quick_create_failed" },
] as never);
qc.setQueryData(agentListOptions("ws1").queryKey, [
{ id: "ag1", name: "Robby", avatar_url: null },
] as never);
qc.setQueryData(runtimeListOptions("ws1").queryKey, [
{ id: "rt1", name: "Claude (host)", custom_name: "Prod Box", status: "online" },
] as never);
}
function presentationOf(url: string, fallback?: string) {
const qc = makeClient();
seed(qc);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(() => useTabPresentation(url, fallback), { wrapper }).result
.current;
}
beforeEach(() => {
ws.current = { id: "ws1", slug: "acme" };
});
describe("useTabPresentation — live from cache", () => {
it("page: page icon + localized page name", () => {
expect(presentationOf("/acme/issues")).toEqual({
visual: { kind: "icon", icon: "ListTodo" },
title: "Issues",
});
});
it("issue: live status glyph + identifier:title", () => {
expect(presentationOf("/acme/issues/i1")).toEqual({
visual: { kind: "issue-status", status: "in_progress" },
title: "MUL-1: Fix login",
});
});
it("project: own icon + title", () => {
expect(presentationOf("/acme/projects/p1")).toEqual({
visual: { kind: "project-icon", icon: "🚀" },
title: "Apollo",
});
});
it("actor: avatar visual + resolved name", () => {
expect(presentationOf("/acme/agents/ag1")).toEqual({
visual: { kind: "actor", actorType: "agent", id: "ag1" },
title: "Robby",
});
});
it("machine tab uses the runtime's custom name over the raw daemon name", () => {
expect(presentationOf("/acme/runtimes/rt1")).toEqual({
visual: { kind: "icon", icon: "Monitor" },
title: "Prod Box",
});
});
it("chat container: MessageSquare icon + session title", () => {
expect(presentationOf("/acme/chat?session=s1")).toEqual({
visual: { kind: "icon", icon: "MessageSquare" },
title: "Deploy plan",
});
});
it("chat container: blank session title uses the New chat fallback", () => {
expect(presentationOf("/acme/chat?session=s2").title).toBe("New chat");
});
it("chat container: no session stays Chat", () => {
expect(presentationOf("/acme/chat")).toEqual({
visual: { kind: "icon", icon: "MessageSquare" },
title: "Chat",
});
});
it("inbox container: selected issue shows Inbox icon + issue title", () => {
// Selection key is issue_id ?? id — n1 links issue i9.
expect(presentationOf("/acme/inbox?issue=i9")).toEqual({
visual: { kind: "icon", icon: "Inbox" },
title: "MUL-9: Crash",
});
});
it("inbox container: selected non-issue shows its display title", () => {
expect(presentationOf("/acme/inbox?issue=n2")).toEqual({
visual: { kind: "icon", icon: "Inbox" },
title: "Quick create failed",
});
});
it("archived inbox: selected issue resolves against the archived list", () => {
// a1 lives only in the archived list; without ?view=archived it must NOT
// resolve (title stays Inbox), and with it, it resolves to i1's title.
expect(presentationOf("/acme/inbox?issue=i1").title).toBe("Inbox");
expect(presentationOf("/acme/inbox?view=archived&issue=i1")).toEqual({
visual: { kind: "icon", icon: "Inbox" },
title: "MUL-1: Fix login",
});
});
it("archived inbox: selected non-issue resolves against the archived list", () => {
expect(presentationOf("/acme/inbox?view=archived&issue=a2")).toEqual({
visual: { kind: "icon", icon: "Inbox" },
title: "Archived note",
});
});
it("attachment: filename from ?name= drives the title and file icon", () => {
expect(presentationOf("/acme/attachments/att1/preview?name=diagram.png")).toEqual({
visual: { kind: "icon", icon: "FileImage" },
title: "diagram.png",
});
// Missing filename → generic File + localized "Attachment".
expect(presentationOf("/acme/attachments/att1/preview")).toEqual({
visual: { kind: "icon", icon: "File" },
title: "Attachment",
});
});
});
describe("useTabPresentation — pending / fallback", () => {
it("pending issue keeps the issue-status slot and uses the persisted fallback", () => {
expect(presentationOf("/acme/issues/unloaded", "MUL-7: Prior")).toEqual({
visual: { kind: "issue-status", status: null },
title: "MUL-7: Prior",
});
});
it("pending issue with no fallback shows the localized type label, not Issues", () => {
const p = presentationOf("/acme/issues/unloaded");
expect(p.visual).toEqual({ kind: "issue-status", status: null });
expect(p.title).toBe("Issue");
});
it("unknown route is neutral, never Issues", () => {
expect(presentationOf("/acme/mystery")).toEqual({
visual: { kind: "icon", icon: "FileQuestion" },
title: "Unknown page",
});
});
it("actor falls back to a type icon before the workspace resolves", () => {
ws.current = null;
expect(presentationOf("/acme/agents/ag1").visual).toEqual({
kind: "icon",
icon: "Bot",
});
});
});
describe("ResourceLeadingVisual", () => {
it("renders a project icon with its emoji", () => {
const { getByText } = render(
<ResourceLeadingVisual visual={{ kind: "project-icon", icon: "🚀" }} />,
);
expect(getByText("🚀")).toBeTruthy();
});
it("renders a status glyph for an issue", () => {
const { container } = render(
<ResourceLeadingVisual visual={{ kind: "issue-status", status: "done" }} />,
);
expect(container.querySelector("svg")).toBeTruthy();
});
it("renders the actor avatar for an actor visual", () => {
const { getByTestId } = render(
<ResourceLeadingVisual
visual={{ kind: "actor", actorType: "agent", id: "ag1" }}
/>,
);
expect(getByTestId("actor-avatar").getAttribute("data-actor")).toBe("agent:ag1");
});
it("renders a lucide icon for a plain icon visual", () => {
const { container } = render(
<ResourceLeadingVisual visual={{ kind: "icon", icon: "Inbox" }} />,
);
expect(container.querySelector("svg")).toBeTruthy();
});
});