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>
83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { paths } from "./paths";
|
|
import {
|
|
WORKSPACE_PAGES,
|
|
DEFAULT_ROUTE_ICON_NAME,
|
|
resolveRouteIconName,
|
|
pageForSegment,
|
|
type WorkspacePageKey,
|
|
} from "./route-icons";
|
|
|
|
// Guards the class of bug where a workspace nav route exists but has no
|
|
// explicit page entry, so it silently falls back to the default (ListTodo) and
|
|
// visually diverges from the rest of the UI. Every parameterless workspace
|
|
// route that shows up in the sidebar/tab bar must map to a WORKSPACE_PAGES
|
|
// entry.
|
|
describe("workspace page coverage", () => {
|
|
// `root` aliases `issues` (same segment) and is never rendered as its own
|
|
// nav item; the parameterized detail routes are resources, not pages.
|
|
const EXCLUDED_METHODS = new Set(["root"]);
|
|
const KNOWN_SEGMENTS = new Set(
|
|
(Object.keys(WORKSPACE_PAGES) as WorkspacePageKey[]).map(
|
|
(k) => WORKSPACE_PAGES[k].segment,
|
|
),
|
|
);
|
|
|
|
it("every parameterless workspace route segment maps to a page", () => {
|
|
const ws = paths.workspace("acme") as unknown as Record<string, () => string>;
|
|
const missing: string[] = [];
|
|
|
|
for (const [method, fn] of Object.entries(ws)) {
|
|
if (typeof fn !== "function" || fn.length !== 0) continue;
|
|
if (EXCLUDED_METHODS.has(method)) continue;
|
|
const segment = fn().split("/").filter(Boolean)[1] ?? "";
|
|
if (!KNOWN_SEGMENTS.has(segment)) missing.push(`${method} → "${segment}"`);
|
|
}
|
|
|
|
expect(
|
|
missing,
|
|
`these nav routes have no page entry (would fall back to ${DEFAULT_ROUTE_ICON_NAME}): ${missing.join(", ")}`,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("pageForSegment", () => {
|
|
it("maps a known segment to its page key", () => {
|
|
expect(pageForSegment("projects")).toBe("projects");
|
|
expect(pageForSegment("my-issues")).toBe("myIssues");
|
|
expect(pageForSegment("settings")).toBe("settings");
|
|
});
|
|
|
|
it("returns null for an unknown segment", () => {
|
|
expect(pageForSegment("not-a-page")).toBeNull();
|
|
expect(pageForSegment("")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveRouteIconName", () => {
|
|
it("resolves a page path to its page icon", () => {
|
|
expect(resolveRouteIconName("/acme/projects")).toBe("FolderKanban");
|
|
expect(resolveRouteIconName("/acme/autopilots")).toBe("Zap");
|
|
expect(resolveRouteIconName("/acme/chat")).toBe("MessageSquare");
|
|
expect(resolveRouteIconName("/acme/squads")).toBe("Users");
|
|
expect(resolveRouteIconName("/acme/usage")).toBe("BarChart3");
|
|
expect(resolveRouteIconName("/acme/my-issues")).toBe("CircleUser");
|
|
});
|
|
|
|
it("gives sub-routes their parent page icon (sidebar semantics)", () => {
|
|
expect(resolveRouteIconName("/acme/projects/proj-123")).toBe("FolderKanban");
|
|
expect(resolveRouteIconName("/acme/issues/bug-42")).toBe("ListTodo");
|
|
});
|
|
|
|
it("ignores the workspace slug and any query/hash", () => {
|
|
expect(resolveRouteIconName("/other-team/projects?x=1#y")).toBe("FolderKanban");
|
|
});
|
|
|
|
it("falls back to the default for unknown or too-short paths", () => {
|
|
expect(resolveRouteIconName("/acme/unknown-route")).toBe(DEFAULT_ROUTE_ICON_NAME);
|
|
expect(resolveRouteIconName("/acme")).toBe(DEFAULT_ROUTE_ICON_NAME);
|
|
expect(resolveRouteIconName("/")).toBe(DEFAULT_ROUTE_ICON_NAME);
|
|
expect(resolveRouteIconName("")).toBe(DEFAULT_ROUTE_ICON_NAME);
|
|
});
|
|
});
|