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>
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/**
|
|
* Semantic target resolver for desktop tabs.
|
|
*
|
|
* A tab points at a URL, but what a tab *is* — a collection page, a specific
|
|
* resource, a container with a selected item, a creation flow, or something
|
|
* unrecognized — is what decides how it should look (icon + title). This module
|
|
* turns a URL into that semantic {@link TabSubject}. It is pure: no React, no
|
|
* Lucide, no query — only URL parsing. The visual/title mapping lives in
|
|
* `tab-presentation.ts`; the React glue lives in `@multica/views`.
|
|
*
|
|
* Workspace URLs are `/{slug}/{segment}/...`. The slug is index 0, the route
|
|
* segment index 1, and any resource id index 2. Container selection (Inbox,
|
|
* Chat) rides in the query string, so it is parsed too — the URL is the single
|
|
* source of truth for what a tab has open, including which item.
|
|
*/
|
|
import { pageForSegment, type WorkspacePageKey } from "./route-icons";
|
|
|
|
export type TabActorType = "agent" | "member" | "squad";
|
|
|
|
export type TabSubject =
|
|
/** A collection or tool page with no specific resource. */
|
|
| { kind: "page"; page: WorkspacePageKey }
|
|
/** A single issue detail. */
|
|
| { kind: "issue"; id: string }
|
|
/** A single project detail. */
|
|
| { kind: "project"; id: string }
|
|
/** A single autopilot detail. */
|
|
| { kind: "autopilot"; id: string }
|
|
/** An agent / member / squad detail (has an avatar identity). */
|
|
| { kind: "actor"; actorType: TabActorType; id: string }
|
|
/** A single skill detail. */
|
|
| { kind: "skill"; id: string }
|
|
/** A runtime machine detail. */
|
|
| { kind: "machine"; machineId: string }
|
|
/** A runtime nested under a machine. */
|
|
| { kind: "runtime"; machineId: string; runtimeId: string }
|
|
/** An attachment preview. `filename` is the `?name=` hint, or null. */
|
|
| { kind: "attachment"; id: string; filename: string | null }
|
|
/**
|
|
* The Inbox container; `selectedKey` is the `?issue=` selection or null,
|
|
* `archived` is the `?view=archived` sub-list (its own list/cache).
|
|
*/
|
|
| { kind: "inbox"; selectedKey: string | null; archived: boolean }
|
|
/** The Chat container; `sessionId` is the `?session=` selection or null. */
|
|
| { kind: "chat"; sessionId: string | null }
|
|
/** A creation flow that has not produced a resource yet. */
|
|
| { kind: "flow"; flow: "create-agent" }
|
|
/** An unrecognized URL. Never impersonate a real page. */
|
|
| { kind: "unknown" };
|
|
|
|
/** Split a URL into its `/`-separated pathname segments and its query params. */
|
|
function splitUrl(url: string): { segments: string[]; query: URLSearchParams } {
|
|
const hashIdx = url.indexOf("#");
|
|
const withoutHash = hashIdx === -1 ? url : url.slice(0, hashIdx);
|
|
const queryIdx = withoutHash.indexOf("?");
|
|
const pathname = queryIdx === -1 ? withoutHash : withoutHash.slice(0, queryIdx);
|
|
const search = queryIdx === -1 ? "" : withoutHash.slice(queryIdx + 1);
|
|
return {
|
|
segments: pathname.split("/").filter(Boolean),
|
|
query: new URLSearchParams(search),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a workspace tab URL into a {@link TabSubject}.
|
|
*
|
|
* Only recognizes workspace-scoped URLs (`/{slug}/{segment}/...`). Anything
|
|
* shorter, empty, or with an unknown segment resolves to `{ kind: "unknown" }`
|
|
* — never to a default page, so an unrecognized tab is visibly neutral rather
|
|
* than masquerading as Issues.
|
|
*/
|
|
export function parseTabSubject(url: string): TabSubject {
|
|
const { segments, query } = splitUrl(url);
|
|
// segments[0] is the workspace slug; the route segment is at index 1.
|
|
const segment = segments[1] ?? "";
|
|
const id = segments[2] ?? "";
|
|
|
|
switch (segment) {
|
|
case "issues":
|
|
return id ? { kind: "issue", id } : { kind: "page", page: "issues" };
|
|
case "my-issues":
|
|
return { kind: "page", page: "myIssues" };
|
|
case "projects":
|
|
return id ? { kind: "project", id } : { kind: "page", page: "projects" };
|
|
case "autopilots":
|
|
return id ? { kind: "autopilot", id } : { kind: "page", page: "autopilots" };
|
|
case "agents":
|
|
if (id === "new") return { kind: "flow", flow: "create-agent" };
|
|
return id
|
|
? { kind: "actor", actorType: "agent", id }
|
|
: { kind: "page", page: "agents" };
|
|
case "members":
|
|
// No members collection route exists; only `/members/:id`.
|
|
return id ? { kind: "actor", actorType: "member", id } : { kind: "unknown" };
|
|
case "squads":
|
|
return id
|
|
? { kind: "actor", actorType: "squad", id }
|
|
: { kind: "page", page: "squads" };
|
|
case "usage":
|
|
return { kind: "page", page: "usage" };
|
|
case "inbox":
|
|
return {
|
|
kind: "inbox",
|
|
selectedKey: query.get("issue") || null,
|
|
archived: query.get("view") === "archived",
|
|
};
|
|
case "chat":
|
|
return { kind: "chat", sessionId: query.get("session") || null };
|
|
case "runtimes":
|
|
if (!id) return { kind: "page", page: "runtimes" };
|
|
// `/runtimes/:machineId/runtime/:runtimeId` — nested runtime.
|
|
if (segments[3] === "runtime" && segments[4]) {
|
|
return { kind: "runtime", machineId: id, runtimeId: segments[4] };
|
|
}
|
|
return { kind: "machine", machineId: id };
|
|
case "skills":
|
|
return id ? { kind: "skill", id } : { kind: "page", page: "skills" };
|
|
case "settings":
|
|
return { kind: "page", page: "settings" };
|
|
case "attachments":
|
|
// `/attachments/:id/preview?name=<filename>`.
|
|
return id
|
|
? { kind: "attachment", id, filename: query.get("name") || null }
|
|
: { kind: "unknown" };
|
|
default: {
|
|
// A bare `/{slug}` (no route segment) is normalized to the default
|
|
// surface elsewhere; treat any other unknown segment as unknown so it
|
|
// does not silently borrow a page's identity.
|
|
const page = pageForSegment(segment);
|
|
return page ? { kind: "page", page } : { kind: "unknown" };
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A stable string identity for a subject, useful as a memo/render key. Distinct
|
|
* subjects that should render the same visual share a key; a selection change
|
|
* inside a container changes it.
|
|
*/
|
|
export function tabSubjectKey(subject: TabSubject): string {
|
|
switch (subject.kind) {
|
|
case "page":
|
|
return `page:${subject.page}`;
|
|
case "issue":
|
|
return `issue:${subject.id}`;
|
|
case "project":
|
|
return `project:${subject.id}`;
|
|
case "autopilot":
|
|
return `autopilot:${subject.id}`;
|
|
case "actor":
|
|
return `actor:${subject.actorType}:${subject.id}`;
|
|
case "skill":
|
|
return `skill:${subject.id}`;
|
|
case "machine":
|
|
return `machine:${subject.machineId}`;
|
|
case "runtime":
|
|
return `runtime:${subject.machineId}:${subject.runtimeId}`;
|
|
case "attachment":
|
|
return `attachment:${subject.id}:${subject.filename ?? ""}`;
|
|
case "inbox":
|
|
return `inbox:${subject.archived ? "archived" : "inbox"}:${subject.selectedKey ?? ""}`;
|
|
case "chat":
|
|
return `chat:${subject.sessionId ?? ""}`;
|
|
case "flow":
|
|
return `flow:${subject.flow}`;
|
|
case "unknown":
|
|
return "unknown";
|
|
}
|
|
}
|