mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +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>
262 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|