Files
multica/packages/views/issues
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
..