Files
multica/packages/views/layout/tab-presentation.tsx
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

302 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
parseTabSubject,
resolveTabPresentation,
useCurrentWorkspace,
type TabSubject,
type TabVisual,
type TabTitleSpec,
type TabEntityData,
type TabLabelKey,
} from "@multica/core/paths";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { autopilotDetailOptions } from "@multica/core/autopilots/queries";
import {
skillDetailOptions,
agentListOptions,
memberListOptions,
squadListOptions,
} from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { runtimeDisplayName } from "@multica/core/runtimes";
import { chatSessionsOptions } from "@multica/core/chat/queries";
import {
inboxListOptions,
archivedInboxListOptions,
} from "@multica/core/inbox/queries";
import { cn } from "@multica/ui/lib/utils";
import { StatusIcon } from "../issues/components";
import { ProjectIcon } from "../projects/components/project-icon";
import { ActorAvatar } from "../common/actor-avatar";
import { getInboxDisplayTitle } from "../inbox/components/inbox-display";
import { useT } from "../i18n";
import { ROUTE_ICON_COMPONENTS } from "./route-icon-components";
/**
* Desktop tab presentation: turn a tab URL into a leading visual and a title,
* live from the query cache. This is the view half of the contract whose pure
* core is `@multica/core/paths` (`parseTabSubject` + `resolveTabPresentation`).
*
* Cache-only reads: every query in `useTabEntityData` is `enabled: false`. It
* observes whatever the pages/directory already loaded and re-renders when that
* data changes, so an open tab's icon/title stay in sync (project renamed,
* issue status changed, chat session retitled) without amplifying requests. A
* resource that has not loaded yet renders a stable type fallback until its
* page fills the cache.
*
* The one exception is an actor tab's avatar: `ResourceLeadingVisual` renders
* `ActorAvatar`, which loads the (workspace-global, sidebar-warmed) member /
* agent / squad directories itself. That is intentional — it resolves the
* avatar and, in turn, the name this hook reads from the same lists.
*/
// Placeholder id for a detail query that this tab doesn't need — its key is
// never populated, so the read returns undefined without any fetch.
const NONE = "__tab_presentation_none__";
// Resource kinds where a persisted title is a good first-frame fallback while
// the live data loads. Flow/unknown/attachment always use their type label.
const PENDING_RESOURCE_KEYS: ReadonlySet<TabLabelKey> = new Set<TabLabelKey>([
"issue",
"project",
"autopilot",
"agent",
"member",
"squad",
"skill",
"machine",
"runtime",
]);
/** Gather cached entity data for a subject. All reads are cache-only. */
function useTabEntityData(subject: TabSubject, wsId: string): TabEntityData {
const { t: chatT } = useT("chat");
// Read both inbox lists cache-only; the archived view keeps its own list, so
// an archived selection has to resolve against the archived cache — the same
// list the InboxPage populates when `?view=archived` is active.
const inboxList = useQuery({ ...inboxListOptions(wsId), enabled: false }).data;
const archivedInboxList = useQuery({
...archivedInboxListOptions(wsId),
enabled: false,
}).data;
const activeInboxList =
subject.kind === "inbox" && subject.archived ? archivedInboxList : inboxList;
const inboxItem =
subject.kind === "inbox" && subject.selectedKey
? (activeInboxList?.find(
(i) => (i.issue_id ?? i.id) === subject.selectedKey,
) ?? null)
: null;
// One issue query serves both a direct issue tab and an inbox-selected issue.
const issueId =
subject.kind === "issue"
? subject.id
: (inboxItem?.issue_id ?? "");
const issue = useQuery({
...issueDetailOptions(wsId, issueId || NONE),
enabled: false,
}).data;
const project = useQuery({
...projectDetailOptions(wsId, subject.kind === "project" ? subject.id : NONE),
enabled: false,
}).data;
const autopilot = useQuery({
...autopilotDetailOptions(
wsId,
subject.kind === "autopilot" ? subject.id : NONE,
),
enabled: false,
}).data;
const skill = useQuery({
...skillDetailOptions(wsId, subject.kind === "skill" ? subject.id : NONE),
enabled: false,
}).data;
const agents = useQuery({ ...agentListOptions(wsId), enabled: false }).data;
const members = useQuery({ ...memberListOptions(wsId), enabled: false }).data;
const squads = useQuery({ ...squadListOptions(wsId), enabled: false }).data;
const runtimes = useQuery({ ...runtimeListOptions(wsId), enabled: false }).data;
const sessions = useQuery({ ...chatSessionsOptions(wsId), enabled: false }).data;
const data: TabEntityData = {};
switch (subject.kind) {
case "issue":
if (issue) {
data.issue = {
identifier: issue.identifier,
title: issue.title,
status: issue.status,
};
}
break;
case "project":
if (project) data.project = { icon: project.icon, title: project.title };
break;
case "autopilot":
if (autopilot) data.autopilot = { title: autopilot.autopilot.title };
break;
case "skill":
if (skill) data.skill = { name: skill.name };
break;
case "actor": {
const name =
subject.actorType === "agent"
? agents?.find((a) => a.id === subject.id)?.name
: subject.actorType === "member"
? members?.find((m) => m.user_id === subject.id)?.name
: squads?.find((s) => s.id === subject.id)?.name;
if (name) data.actorName = name;
break;
}
case "machine": {
const rt = runtimes?.find((r) => r.id === subject.machineId);
if (rt) data.machine = { name: runtimeDisplayName(rt) };
break;
}
case "runtime": {
const rt = runtimes?.find((r) => r.id === subject.runtimeId);
if (rt) data.runtime = { name: runtimeDisplayName(rt) };
break;
}
case "chat":
if (subject.sessionId) {
const s = sessions?.find((x) => x.id === subject.sessionId);
if (s) data.chatSessionTitle = s.title?.trim() || chatT(($) => $.window.untitled);
}
break;
case "inbox":
if (inboxItem) {
if (inboxItem.issue_id && issue) {
data.inboxSelection = {
kind: "issue",
identifier: issue.identifier,
title: issue.title,
};
} else if (!inboxItem.issue_id) {
data.inboxSelection = {
kind: "item",
title: getInboxDisplayTitle(inboxItem),
};
}
}
break;
}
return data;
}
/** Localize a title spec, preferring a persisted fallback while pending. */
function useTabTitle(spec: TabTitleSpec, fallbackTitle?: string): string {
const { t: layoutT } = useT("layout");
switch (spec.kind) {
case "text":
return spec.text;
case "nav":
return layoutT(($) => $.nav[spec.navKey]);
case "tab": {
if (PENDING_RESOURCE_KEYS.has(spec.tabKey)) {
const clean = fallbackTitle?.trim();
if (clean) return clean;
}
return layoutT(($) => $.tab[spec.tabKey]);
}
}
}
export interface TabPresentationResult {
visual: TabVisual;
title: string;
}
/**
* Resolve a tab URL into its live leading visual and title.
*
* `fallbackTitle` (the tab's persisted title) is used only as a first-frame
* fallback for a still-loading resource; once the cache resolves, the live
* presentation wins.
*/
export function useTabPresentation(
url: string,
fallbackTitle?: string,
): TabPresentationResult {
const subject = useMemo(() => parseTabSubject(url), [url]);
const ws = useCurrentWorkspace();
const wsId = ws?.id ?? "";
const data = useTabEntityData(subject, wsId);
const { visual, title: titleSpec } = resolveTabPresentation(subject, data);
const title = useTabTitle(titleSpec, fallbackTitle);
// The actor avatar resolves through workspace directory queries and throws
// if rendered before the workspace exists. Until it does, show a type icon.
const safeVisual: TabVisual =
visual.kind === "actor" && !wsId
? {
kind: "icon",
icon:
visual.actorType === "squad"
? "Users"
: visual.actorType === "member"
? "CircleUser"
: "Bot",
}
: visual;
return { visual: safeVisual, title };
}
/**
* Render a tab's leading visual into a fixed 16×16 slot so the tab never
* reflows when the visual resolves from a type fallback to the real identity.
* Shared by the desktop tab bar (and reusable by any resource row that wants
* the same identity rules).
*/
export function ResourceLeadingVisual({
visual,
className,
}: {
visual: TabVisual;
className?: string;
}) {
let inner: React.ReactNode;
switch (visual.kind) {
case "icon": {
const Icon = ROUTE_ICON_COMPONENTS[visual.icon];
inner = <Icon className="size-3.5" />;
break;
}
case "issue-status":
// A null status (loading) renders StatusIcon's neutral fallback glyph.
inner = <StatusIcon status={visual.status ?? ""} className="size-3.5" />;
break;
case "project-icon":
inner = <ProjectIcon project={{ icon: visual.icon }} size="sm" />;
break;
case "actor":
inner = (
<ActorAvatar
actorType={visual.actorType}
actorId={visual.id}
size="xs"
profileLink={false}
/>
);
break;
}
return (
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center",
className,
)}
>
{inner}
</span>
);
}