mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56: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>
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
/**
|
|
* Pure presentation resolver for desktop tabs.
|
|
*
|
|
* Given a {@link TabSubject} (what the tab points at) and whatever entity data
|
|
* is currently available from cache, produce the tab's leading *visual* and its
|
|
* *title* spec. This is the single place the "what should this tab look like"
|
|
* decision lives — icon and title no longer come from two unrelated code paths.
|
|
*
|
|
* It is pure and React-free: the visual is a descriptor (rendered by
|
|
* `@multica/views`' `ResourceLeadingVisual`) and the title is a spec that is
|
|
* either literal text or a localization key (localized by the view layer).
|
|
* Keeping it pure makes the whole "URL + data → icon + title" matrix unit
|
|
* testable without React, which is exactly what the tab behavior needs guarded.
|
|
*
|
|
* Missing entity data is a first-class state: a resource whose data has not
|
|
* loaded yet renders a stable type icon and a type label, never a wrong or
|
|
* empty identity, and never borrows the Issues icon.
|
|
*/
|
|
import type { IssueStatus } from "../types";
|
|
import {
|
|
WORKSPACE_PAGES,
|
|
type NavLabelKey,
|
|
type RouteIconName,
|
|
} from "./route-icons";
|
|
import type { TabActorType, TabSubject } from "./tab-subject";
|
|
|
|
/** The leading visual a tab should render. */
|
|
export type TabVisual =
|
|
/** A static Lucide icon (page icon or resourceless type icon). */
|
|
| { kind: "icon"; icon: RouteIconName }
|
|
/** An issue's live status glyph. `null` while the issue is loading. */
|
|
| { kind: "issue-status"; status: IssueStatus | null }
|
|
/** A project's own icon. `null` falls back to the default project glyph. */
|
|
| { kind: "project-icon"; icon: string | null }
|
|
/** An actor's avatar, resolved by the view layer from `actorType`+`id`. */
|
|
| { kind: "actor"; actorType: TabActorType; id: string };
|
|
|
|
/** Localization keys under the `layout.tab` namespace for tab type labels. */
|
|
export type TabLabelKey =
|
|
| "issue"
|
|
| "project"
|
|
| "autopilot"
|
|
| "agent"
|
|
| "member"
|
|
| "squad"
|
|
| "skill"
|
|
| "machine"
|
|
| "runtime"
|
|
| "attachment"
|
|
| "create_agent"
|
|
| "unknown";
|
|
|
|
/** How a tab's title should be produced. */
|
|
export type TabTitleSpec =
|
|
/** Fully resolved literal text (a resource's own name/title). */
|
|
| { kind: "text"; text: string }
|
|
/** A page name — localize via `layout.nav.<navKey>`. */
|
|
| { kind: "nav"; navKey: NavLabelKey }
|
|
/** A type label (loading / flow / unknown) — localize via `layout.tab.<tabKey>`. */
|
|
| { kind: "tab"; tabKey: TabLabelKey };
|
|
|
|
export interface TabPresentation {
|
|
visual: TabVisual;
|
|
title: TabTitleSpec;
|
|
}
|
|
|
|
/** Resolved inbox selection, as computed by the view layer from cache. */
|
|
export type InboxSelectionData =
|
|
| { kind: "issue"; identifier: string; title: string }
|
|
| { kind: "item"; title: string };
|
|
|
|
/**
|
|
* Entity data the view layer has resolved from cache for the tab's subject.
|
|
* Every field is optional: `undefined` means "not loaded yet" and yields the
|
|
* pending (type-label + type-icon) presentation.
|
|
*/
|
|
export interface TabEntityData {
|
|
issue?: { identifier: string; title: string; status: IssueStatus };
|
|
project?: { icon: string | null; title: string };
|
|
autopilot?: { title: string };
|
|
/** Resolved display name for an actor subject. */
|
|
actorName?: string;
|
|
skill?: { name: string };
|
|
machine?: { name: string };
|
|
runtime?: { name: string };
|
|
/** Resolved chat session title (already includes the "New chat" fallback). */
|
|
chatSessionTitle?: string;
|
|
/** Resolved inbox selection (issue identifier/title or item title). */
|
|
inboxSelection?: InboxSelectionData;
|
|
}
|
|
|
|
/** Neutral visual used when nothing better can be resolved. */
|
|
export const DEFAULT_TAB_VISUAL: TabVisual = { kind: "icon", icon: "FileQuestion" };
|
|
|
|
/** Literal text if non-empty, otherwise the given type label. */
|
|
function textOr(text: string | undefined | null, tabKey: TabLabelKey): TabTitleSpec {
|
|
const trimmed = text?.trim();
|
|
return trimmed ? { kind: "text", text: trimmed } : { kind: "tab", tabKey };
|
|
}
|
|
|
|
const ACTOR_LABEL: Record<TabActorType, TabLabelKey> = {
|
|
agent: "agent",
|
|
member: "member",
|
|
squad: "squad",
|
|
};
|
|
|
|
// Extension → file-type icon. The preview URL only carries the filename, so the
|
|
// extension is the available signal; anything unrecognized uses the generic
|
|
// File glyph.
|
|
const EXTENSION_ICON: Record<string, RouteIconName> = {};
|
|
const registerExtensions = (icon: RouteIconName, exts: string[]) => {
|
|
for (const ext of exts) EXTENSION_ICON[ext] = icon;
|
|
};
|
|
registerExtensions("FileImage", [
|
|
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif", "heic",
|
|
]);
|
|
registerExtensions("FileVideo", ["mp4", "mov", "webm", "mkv", "avi", "m4v"]);
|
|
registerExtensions("FileAudio", ["mp3", "wav", "ogg", "flac", "m4a", "aac"]);
|
|
registerExtensions("FileArchive", ["zip", "tar", "gz", "tgz", "rar", "7z", "bz2"]);
|
|
registerExtensions("FileCode", [
|
|
"js", "jsx", "ts", "tsx", "json", "yaml", "yml", "py", "go", "rs",
|
|
"java", "c", "h", "cpp", "cc", "rb", "php", "swift", "kt", "sh", "css", "scss",
|
|
]);
|
|
registerExtensions("FileText", [
|
|
"txt", "md", "markdown", "pdf", "doc", "docx", "csv", "log",
|
|
"html", "htm", "xml", "rtf", "odt",
|
|
]);
|
|
|
|
/** Choose the file icon for an attachment from its filename, else generic File. */
|
|
export function iconForAttachment(filename: string | null): RouteIconName {
|
|
if (!filename) return "File";
|
|
const dot = filename.lastIndexOf(".");
|
|
if (dot < 0 || dot === filename.length - 1) return "File";
|
|
const ext = filename.slice(dot + 1).toLowerCase();
|
|
return EXTENSION_ICON[ext] ?? "File";
|
|
}
|
|
|
|
/**
|
|
* Resolve a subject + available data into the tab's leading visual and title.
|
|
* Exhaustive over every {@link TabSubject} kind so a new route/resource type
|
|
* forces an explicit presentation choice rather than a silent default.
|
|
*/
|
|
export function resolveTabPresentation(
|
|
subject: TabSubject,
|
|
data: TabEntityData = {},
|
|
): TabPresentation {
|
|
switch (subject.kind) {
|
|
case "page": {
|
|
const page = WORKSPACE_PAGES[subject.page];
|
|
return {
|
|
visual: { kind: "icon", icon: page.icon },
|
|
title: { kind: "nav", navKey: page.navKey },
|
|
};
|
|
}
|
|
case "issue":
|
|
return {
|
|
visual: { kind: "issue-status", status: data.issue?.status ?? null },
|
|
title: data.issue
|
|
? { kind: "text", text: `${data.issue.identifier}: ${data.issue.title}` }
|
|
: { kind: "tab", tabKey: "issue" },
|
|
};
|
|
case "project":
|
|
return {
|
|
visual: { kind: "project-icon", icon: data.project?.icon ?? null },
|
|
title: textOr(data.project?.title, "project"),
|
|
};
|
|
case "autopilot":
|
|
return {
|
|
visual: { kind: "icon", icon: "Zap" },
|
|
title: textOr(data.autopilot?.title, "autopilot"),
|
|
};
|
|
case "actor":
|
|
return {
|
|
visual: { kind: "actor", actorType: subject.actorType, id: subject.id },
|
|
title: textOr(data.actorName, ACTOR_LABEL[subject.actorType]),
|
|
};
|
|
case "skill":
|
|
return {
|
|
visual: { kind: "icon", icon: "BookOpenText" },
|
|
title: textOr(data.skill?.name, "skill"),
|
|
};
|
|
case "machine":
|
|
return {
|
|
visual: { kind: "icon", icon: "Monitor" },
|
|
title: textOr(data.machine?.name, "machine"),
|
|
};
|
|
case "runtime":
|
|
return {
|
|
visual: { kind: "icon", icon: "Server" },
|
|
title: textOr(data.runtime?.name, "runtime"),
|
|
};
|
|
case "attachment":
|
|
// The preview URL carries the filename (`?name=`), so use it for the
|
|
// title and pick a matching file icon from its extension. Only fall back
|
|
// to the generic File glyph + "Attachment" label when it's missing.
|
|
return {
|
|
visual: { kind: "icon", icon: iconForAttachment(subject.filename) },
|
|
title: subject.filename
|
|
? { kind: "text", text: subject.filename }
|
|
: { kind: "tab", tabKey: "attachment" },
|
|
};
|
|
case "inbox": {
|
|
// The container icon never changes; only the title tracks the selection.
|
|
const sel = subject.selectedKey ? data.inboxSelection : undefined;
|
|
let title: TabTitleSpec;
|
|
if (!sel) {
|
|
title = { kind: "nav", navKey: "inbox" };
|
|
} else if (sel.kind === "issue") {
|
|
title = { kind: "text", text: `${sel.identifier}: ${sel.title}` };
|
|
} else {
|
|
const text = sel.title.trim();
|
|
title = text ? { kind: "text", text } : { kind: "nav", navKey: "inbox" };
|
|
}
|
|
return { visual: { kind: "icon", icon: "Inbox" }, title };
|
|
}
|
|
case "chat": {
|
|
const title: TabTitleSpec =
|
|
subject.sessionId && data.chatSessionTitle?.trim()
|
|
? { kind: "text", text: data.chatSessionTitle.trim() }
|
|
: { kind: "nav", navKey: "chat" };
|
|
return { visual: { kind: "icon", icon: "MessageSquare" }, title };
|
|
}
|
|
case "flow":
|
|
return {
|
|
visual: { kind: "icon", icon: "Bot" },
|
|
title: { kind: "tab", tabKey: "create_agent" },
|
|
};
|
|
case "unknown":
|
|
return {
|
|
visual: DEFAULT_TAB_VISUAL,
|
|
title: { kind: "tab", tabKey: "unknown" },
|
|
};
|
|
}
|
|
}
|