mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +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>
302 lines
9.7 KiB
TypeScript
302 lines
9.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|