mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 13:23:48 +08:00
* perf(inbox): derive the unread badge from the summary endpoint, not the full list
The sidebar nav badge, the desktop dock badge and the mobile tab badge were
all computed by downloading the entire inbox with `GET /api/inbox` and
counting it client-side. That query has no LIMIT and no pagination, so every
app start pulled every unarchived notification the user has — each carrying a
full untruncated comment body — to render one number, whether or not the user
ever opened the Inbox.
`GET /api/inbox/unread-summary` already returns that number: one small row per
workspace, and its SQL applies the same newest-per-issue rule
`deduplicateInboxItems` applies before render, so the count is unchanged. Web
already fetches it for the workspace-switcher dot, so the badge there now
costs no request at all; mobile trades an unbounded list fetch for this one.
`GET /api/inbox/unread-count` is deliberately not used: it counts raw rows, so
one issue with three unread notifications would read as 3 where the inbox
shows a single row. Documented on the client method so the trap is visible.
The badge is now server-computed, so the optimistic list patches no longer
move it on their own. Each mutation that patches the list re-derives the
workspace's summary entry from the patched rows through the same dedup helper
the inbox renders through — the badge still moves in the same frame and
cannot disagree with the rows on screen. Mutations that cannot predict the
outcome invalidate the summary on settle instead, which they now all do: it
lives under an account-level key that `inboxKeys.all(wsId)` does not reach.
Refs MUL-6967.
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): make the unread badge server-owned and resolve tabs by object
Addresses review on #7924.
The badge no longer derives from the list cache. Recomputing a workspace-wide
count from `deduplicateInboxItems(listCache)` and writing it into the summary
read as instant, but a list cache proves only that the list was loaded once —
never that it is complete or concurrent with the summary — and the
account-level summary query is not covered by the workspace-scoped
`cancelQueries`, so a response already in flight lands on top of the local
value anyway. Under pagination it would be wrong by construction: one loaded
page cannot produce a global count. Rows stay optimistic; the badge follows
the server, which costs a round-trip and buys a single writer.
`issue:deleted` now refreshes the summary. It is an `issue:*` event, so no
`inbox:*` handler runs to pick it up, and the summary query is
`staleTime: Infinity` with no refetch on focus — deleting the last unread
issue emptied the list and left the badge lit with nothing to correct it. The
invalidation lives inside the updater on both platforms so no call site can
forget it.
Inbox tab titles resolve per object instead of through the list. `selectedKey`
IS the key the inbox groups by (`issue_id ?? id`), so for an issue-backed
notification it already IS the issue id — the hop through the list row was
redundant, and it was the only reason a restored tab needed the whole Inbox
fetched first. An issue-less notification still reads its row, since its title
exists nowhere else, and an unresolved selection now keeps the tab's persisted
title as its first frame rather than collapsing to "Inbox".
A selection whose row lives in the other view now titles by object rather than
resolving to "Inbox": treating "not in the page I loaded" as "does not exist"
is what this should not do, and the page already redirects such a deep link to
the issue.
Every added test was confirmed failing on b0d06e167 and passing after.
Refs MUL-6967.
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): cancel an in-flight summary request before invalidating it
Addresses the re-review on #7924.
Refreshing the unread summary by invalidation alone does not converge when it
races the summary's FIRST load. TanStack only cancels an in-flight request on
invalidation once the query already holds data — `Query.fetch` guards that
branch on `state.data !== undefined` and otherwise hands back the request
already on the wire:
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
this.cancel({ silent: true })
} else if (this.#retryer) {
return this.#retryer.promise // ← the pre-change request
}
So the sequence "open Inbox, mark read before the first summary answers" is
served by the pre-change response, which resolves successfully and clears
`isInvalidated`. With `staleTime: Infinity` and no refetch on focus, nothing
asks again: the badge keeps a count the user's own action already invalidated
until some unrelated event happens to refresh it. This is not about restoring
an instant badge — it fails to converge after the server has confirmed.
Both platforms now route every summary refresh through one entry point that
cancels first, then invalidates: mutations, inbox events, issue deletion and
reconnect. Cancelling only matters on a first load, so this makes a refresh
behave identically whether or not the summary has loaded yet. The core
mutation helper no longer keeps its own copy — a mutation racing the first
load hits exactly the same hole a WS event does.
The convergence test this replaces did not create the race it claimed to
cover: it waited for the badge to read 1 first, so the first response had
already resolved and it only exercised an ordinary sequential update. The new
one holds that response open across the write and the event, and is
parametrized over cached/uncached — confirmed failing on the uncached case
before this change and passing on both after, matching the review's finding.
Refs MUL-6967.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>