mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* 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>
312 lines
10 KiB
TypeScript
312 lines
10 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 ?? "");
|
||
// 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>
|
||
);
|
||
}
|