Files
multica/packages/core/paths/tab-presentation.ts
Naiyuan Qing 2f111037d2 feat(desktop): tab presentation by object identity (MUL-4370) (#5661)
* 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>
2026-07-22 09:36:53 +08:00

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" },
};
}
}