Files
multica/packages/views/layout/tab-presentation.tsx
Bohan Jiang 577018649e feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354)

Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link
action shares that form, and a UUID URL rewrites itself to it. Existing
UUID links keep working.

Backend already resolved identifiers on `GET /api/issues/{id}`, but it
compared the number only — every prefix with the right number opened the
same issue, so no identifier URL could be canonical. Resolution now
validates the prefix against the workspace's own (case-insensitively,
matching `lookupIssueByIdentifier`), and the number parser bails on
int32 overflow instead of truncating a digits-only UUID group into a
plausible issue number.

On the client the identifier stays a presentation concern: the route
resolves it to the UUID before rendering, because the realtime updaters
patch `issueKeys.detail(wsId, issue.id)` with the UUID from the
websocket payload. A view keyed on the identifier would sit on a cache
entry no realtime event can reach and silently stop updating. Resolution
reuses the request the detail view would have made anyway and seeds the
UUID-keyed entry, so an identifier URL costs no extra round trip. The
desktop tab title/status glyph hops through the same resolution for the
same reason.

The URL rewrite lives in the new route wrapper rather than IssueDetail:
the inbox renders IssueDetail in a side panel, where replacing the URL
would navigate the user out of the inbox.

No migration — `issue (workspace_id, number)` is already unique/indexed.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): make the single-request guarantee for identifier URLs explicit

Review flagged that opening `/{ws}/issues/MUL-123` fires two detail
requests. It does not, under the app's own QueryClient — but the
guarantee was resting on something implicit, so make it structural.

The old shape seeded the UUID-keyed entry from a `useEffect` after
resolution, while the route enabled the UUID query in the same render.
That held only because the seed effect happened to be declared before
the UUID query's own effect, and because `createQueryClient` sets
`staleTime: Infinity` so a seeded entry is never refetched. Neither is
obvious from the code, and a diagnostic run under a bare `new
QueryClient()` (staleTime 0) does show two calls — the second being a
staleness refetch of an already-seeded entry, i.e. a harness artifact.

`useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the
resolution query and the canonical detail query and hands the resolution
response to the latter as `initialData`. That is applied while the
observer is created, so the canonical query never observes an empty
cache and never starts a fetch of its own — no dependency on effect
ordering, and no cache write that could race a realtime patch
(`initialData` is ignored once the entry holds data).

Callers collapse to one hook each: the route no longer runs its own
detail query, and the desktop page drops its duplicate.

Tests now build the client with `createQueryClient()` rather than a bare
`new QueryClient()`, so request-count assertions measure production
behavior instead of the harness, plus a direct assertion that an
identifier URL costs exactly one request.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): stop the request loop when an identifier names no issue

Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an
unbounded request loop and left the UI on the loading skeleton forever.

The route treated a failed resolution as "nothing resolved" and handed
the raw identifier down to IssueDetail. IssueDetail mounted a second
observer on the query that had just failed; `retryOnMount` refetched it,
which flipped the resolve hook back to pending, which unmounted
IssueDetail, which remounted it when the refetch failed — and around
again. Measured with retry disabled to isolate it: 8,192 requests at
300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only
paces the loop, it still never converges.

`useCanonicalIssue` now reports a terminal `notFound` read from the
resolution query's own error state, rather than leaving callers to infer
failure from "not resolving and no id" — an inference that cannot
distinguish failed from in-flight. `IssueDetailRoute` renders the
not-found UI itself and never hands an unresolved segment to a view that
would query it again, so no second observer exists to restart the cycle.
Same measurement after the fix: 1 request, settled, "not found" on
screen.

The not-found UI moves out of IssueDetail into a shared `IssueNotFound`
so both render the identical state.

Regression tests at both levels, with retry off so any count above 1 can
only be a remount refetch: the hook settles a failed resolution without
looping, and the real IssueDetailRoute holds at one request across
waits and rerenders. Both fail against the previous code.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 14:22:13 +08:00

312 lines
10 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 ?? "");
// An issue tab's URL segment may be a human-readable identifier (`MUL-123`).
// The route seeds that entry when it resolves, but only the UUID-keyed entry
// receives realtime patches — so hop through it, or a tab opened by
// identifier would freeze on the title and status it had when first opened.
// Both reads stay cache-only, and a UUID segment resolves to itself.
const rawIssue = useQuery({
...issueDetailOptions(wsId, issueId || NONE),
enabled: false,
}).data;
const issue =
useQuery({
...issueDetailOptions(wsId, rawIssue?.id || NONE),
enabled: false,
}).data ?? rawIssue;
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>
);
}