mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +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>
112 lines
4.7 KiB
TypeScript
112 lines
4.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { parseTabSubject, tabSubjectKey, type TabSubject } from "./tab-subject";
|
|
|
|
describe("parseTabSubject", () => {
|
|
// Table-driven over every desktop workspace route, including collection vs
|
|
// detail, the /new flow, nested runtime, container query selection, and
|
|
// unknown paths. A new route type that isn't handled here will surface as
|
|
// `unknown` — which the presentation layer renders neutrally, never as Issues.
|
|
const cases: Array<[string, TabSubject]> = [
|
|
// Collection / tool pages
|
|
["/acme/issues", { kind: "page", page: "issues" }],
|
|
["/acme/my-issues", { kind: "page", page: "myIssues" }],
|
|
["/acme/projects", { kind: "page", page: "projects" }],
|
|
["/acme/autopilots", { kind: "page", page: "autopilots" }],
|
|
["/acme/agents", { kind: "page", page: "agents" }],
|
|
["/acme/squads", { kind: "page", page: "squads" }],
|
|
["/acme/usage", { kind: "page", page: "usage" }],
|
|
["/acme/runtimes", { kind: "page", page: "runtimes" }],
|
|
["/acme/skills", { kind: "page", page: "skills" }],
|
|
["/acme/settings", { kind: "page", page: "settings" }],
|
|
// Resource details
|
|
["/acme/issues/bug-1", { kind: "issue", id: "bug-1" }],
|
|
["/acme/projects/p1", { kind: "project", id: "p1" }],
|
|
["/acme/autopilots/a1", { kind: "autopilot", id: "a1" }],
|
|
["/acme/skills/s1", { kind: "skill", id: "s1" }],
|
|
["/acme/attachments/att1/preview", { kind: "attachment", id: "att1", filename: null }],
|
|
[
|
|
"/acme/attachments/att1/preview?name=report.pdf",
|
|
{ kind: "attachment", id: "att1", filename: "report.pdf" },
|
|
],
|
|
[
|
|
"/acme/attachments/att1/preview?name=my%20photo.png",
|
|
{ kind: "attachment", id: "att1", filename: "my photo.png" },
|
|
],
|
|
// Actors
|
|
["/acme/agents/ag1", { kind: "actor", actorType: "agent", id: "ag1" }],
|
|
["/acme/members/m1", { kind: "actor", actorType: "member", id: "m1" }],
|
|
["/acme/squads/sq1", { kind: "actor", actorType: "squad", id: "sq1" }],
|
|
// Flow — /new must win over the actor detail pattern
|
|
["/acme/agents/new", { kind: "flow", flow: "create-agent" }],
|
|
// Runtime machine vs nested runtime
|
|
["/acme/runtimes/machine-1", { kind: "machine", machineId: "machine-1" }],
|
|
[
|
|
"/acme/runtimes/machine-1/runtime/rt-2",
|
|
{ kind: "runtime", machineId: "machine-1", runtimeId: "rt-2" },
|
|
],
|
|
// Containers — selection (and archived sub-list) live in the query string
|
|
["/acme/inbox", { kind: "inbox", selectedKey: null, archived: false }],
|
|
["/acme/inbox?issue=MUL-9", { kind: "inbox", selectedKey: "MUL-9", archived: false }],
|
|
["/acme/inbox?view=archived", { kind: "inbox", selectedKey: null, archived: true }],
|
|
[
|
|
"/acme/inbox?view=archived&issue=MUL-9",
|
|
{ kind: "inbox", selectedKey: "MUL-9", archived: true },
|
|
],
|
|
["/acme/chat", { kind: "chat", sessionId: null }],
|
|
["/acme/chat?session=sess-1", { kind: "chat", sessionId: "sess-1" }],
|
|
["/acme/chat?agent=ag-1", { kind: "chat", sessionId: null }],
|
|
// Members list route does not exist
|
|
["/acme/members", { kind: "unknown" }],
|
|
// Unknown / too short
|
|
["/acme/nope", { kind: "unknown" }],
|
|
["/acme", { kind: "unknown" }],
|
|
["/", { kind: "unknown" }],
|
|
["", { kind: "unknown" }],
|
|
];
|
|
|
|
it.each(cases)("parses %s", (url, expected) => {
|
|
expect(parseTabSubject(url)).toEqual(expected);
|
|
});
|
|
|
|
it("ignores hash fragments", () => {
|
|
expect(parseTabSubject("/acme/issues/bug-1#comment-3")).toEqual({
|
|
kind: "issue",
|
|
id: "bug-1",
|
|
});
|
|
expect(parseTabSubject("/acme/chat?session=s1#x")).toEqual({
|
|
kind: "chat",
|
|
sessionId: "s1",
|
|
});
|
|
});
|
|
|
|
it("keeps sub-route ids at index 2 (issue filters/anchors don't leak)", () => {
|
|
expect(parseTabSubject("/acme/issues/bug-1?comment=c1")).toEqual({
|
|
kind: "issue",
|
|
id: "bug-1",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("tabSubjectKey", () => {
|
|
it("is stable for the same subject and distinct across selections", () => {
|
|
expect(tabSubjectKey({ kind: "issue", id: "x" })).toBe("issue:x");
|
|
expect(tabSubjectKey({ kind: "chat", sessionId: null })).toBe("chat:");
|
|
expect(tabSubjectKey({ kind: "chat", sessionId: "s1" })).toBe("chat:s1");
|
|
expect(
|
|
tabSubjectKey({ kind: "actor", actorType: "member", id: "m1" }),
|
|
).toBe("actor:member:m1");
|
|
});
|
|
|
|
it("distinguishes archived inbox and the attachment filename", () => {
|
|
expect(tabSubjectKey({ kind: "inbox", selectedKey: "k", archived: false })).toBe(
|
|
"inbox:inbox:k",
|
|
);
|
|
expect(tabSubjectKey({ kind: "inbox", selectedKey: "k", archived: true })).toBe(
|
|
"inbox:archived:k",
|
|
);
|
|
expect(
|
|
tabSubjectKey({ kind: "attachment", id: "a1", filename: "x.pdf" }),
|
|
).toBe("attachment:a1:x.pdf");
|
|
});
|
|
});
|