Files
multica/packages/core/issues/cache-helpers.ts
Naiyuan Qing ade6b34e5f MUL-3903: Extract shared issue surfaces (#4774)
* MUL-3903 refactor project issue surface state

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

* Refactor project issue surface ownership

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

* Extract shared issue surface entrypoints

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

* Fix issue surface create defaults and selection reset

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

* test(editor): add missing AbortSignal to suggestion items() calls

The suggestion items() contract gained a required signal param; the
mention/slash test call sites were never updated, breaking pnpm typecheck
for @multica/views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): server-side assignee_types filter on ListIssues

ListGroupedIssues has taken assignee_types since squads shipped, but
ListIssues never did — so the workspace Members/Agents tabs had to fetch
the unfiltered workspace list and post-filter loaded pages client-side,
which made column totals and load-more pagination reflect the unfiltered
counts.

Add the same parse + WHERE clause to ListIssues (count query shares the
WHERE, so totals agree), thread the param through the TS client, and
widen MyIssuesFilter so scoped list caches can carry it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): route issue cache writes through a membership-aware coordinator

useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler
each maintained their own similar-but-diverging patch/invalidate rules.
Consolidate them into cache-coordinator.ts (applyIssueChange /
rollbackIssueChange / invalidateIssueDerivatives) so local writes and
remote echoes follow one rules table by construction.

The coordinator is membership-aware via surface/membership.ts
(true | false | unknown against each list cache's own filter contract):

- a change that moves an issue off a filtered surface removes the card
  surgically (bucket total decremented) — fixes assignee changes leaving
  stale cards on My Assigned with no local safety net (previously only
  the WS echo recovered it), and replaces the blanket invalidate-myAll
  net for project moves (MUL-3669) with per-key precision
- possible entry into a loaded list marks that key stale — never
  hard-insert; page/slot is server knowledge
- stale keys flush on settle for mutations (a mid-flight refetch would
  stomp the optimistic state) and immediately for WS
- batch updates now patch detail + inbox like single updates; the
  off-screen bucket-count recovery previously exclusive to the WS path
  now covers local mutations too

Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375
control-field stripping, and no refetch of surgically reconciled lists
(the drag-flicker fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount

Read-path convergence and the loading/empty semantics that fall out of
it:

- scope -> API params moves from scope.ts helpers into
  surface/query-plan.ts; workspace members/agents become server-filtered
  scoped plans (assignee_types) and the client postFilter machinery is
  deleted — tab counts and load-more are now exact
- query selection moves behind surface/repository.ts; the views data
  hook no longer branches on workspace-vs-scoped plumbing
- IssueSurfaceContent remounts on data-window change (wsId + scope):
  keepPreviousData placeholders keep sort/filter changes flicker-free
  within one window but must never let project A's (or workspace A's)
  cards impersonate B's with no loading state — cold window shows the
  skeleton, warm window hits cache instantly
- isEmpty is only asserted from full-window data; the gantt
  scheduled-only projection can't prove the window is empty, so GanttView's
  own "no scheduled issues" empty state renders instead of the generic
  create-issue one
- per-card project lookups hoist into a surface-level projectMap (drops
  a per-card useQuery), create-defaults typing tightens to
  IssueCreateDefaults

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(issues): count-only arithmetic for off-window status/membership changes

An issue beyond a list's loaded page window used to force a full
first-page refetch just to fix two column counts. When the change is
CERTAIN (base entity known, membership definitive) the coordinator now
does the arithmetic locally:

- stayed a member + status changed: move one unit of total between the
  two buckets (loaded arrays untouched; hasMore stays consistent)
- left the list (reassigned / re-projected): old status bucket total -1
- member-to-member reassignment: counts unaffected, not even a stale key

Entering a list and any uncertainty (no base, unknown membership) still
refetch — the right page/slot is server knowledge. Branches on membership
OUTCOMES, not on which field changed, so future dimensions (team) join
automatically. Biggest win is the WS path: agents flipping off-screen
statuses no longer trigger refetch storms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): deferred view-refresh indicator during placeholder revalidation

Sort/date changes (and any grouped-board filter change) revalidate behind
the previous snapshot — correct, but on a slow network the click felt
dead: content stays put and isLoading never fires. Surface the state as
isRefreshing (isPlaceholderData of the active query) and render a shared
ViewRefreshIndicator in every issues header: a fixed-width slot (zero
layout shift) whose spinner fades in after 300ms, so sub-second responses
show nothing (NN/g) while slow ones get a working signal.

Bound to the revalidation STATE, not to any particular control — any
current or future server-side view change lights it automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:11:10 +08:00

170 lines
5.4 KiB
TypeScript

import type {
Issue,
IssueStatus,
IssueStatusBucket,
ListIssuesCache,
} from "../types";
import { PAGINATED_STATUSES } from "./queries";
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
export function getBucket(
resp: ListIssuesCache,
status: IssueStatus,
): IssueStatusBucket {
return resp.byStatus[status] ?? EMPTY_BUCKET;
}
export function setBucket(
resp: ListIssuesCache,
status: IssueStatus,
bucket: IssueStatusBucket,
): ListIssuesCache {
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
}
/** Locate which status bucket holds `id`, if any. */
export function findIssueLocation(
resp: ListIssuesCache,
id: string,
): { status: IssueStatus; issue: Issue } | null {
for (const status of PAGINATED_STATUSES) {
const bucket = resp.byStatus[status];
const found = bucket?.issues.find((i) => i.id === id);
if (found) return { status, issue: found };
}
return null;
}
/** Add an issue to its status bucket (no-op if already present). */
export function addIssueToBuckets(
resp: ListIssuesCache,
issue: Issue,
): ListIssuesCache {
const bucket = getBucket(resp, issue.status);
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
return setBucket(resp, issue.status, {
issues: [...bucket.issues, issue],
total: bucket.total + 1,
});
}
/** Remove an issue from whichever bucket contains it. */
export function removeIssueFromBuckets(
resp: ListIssuesCache,
id: string,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
issues: bucket.issues.filter((i) => i.id !== id),
total: Math.max(0, bucket.total - 1),
});
}
/**
* Count-only reconcile for an issue BEYOND the loaded window: its status
* changed server-side, so one unit of `total` moves between buckets while
* the loaded arrays stay untouched. `hasMore` (loaded < total) stays
* consistent for free.
*/
export function moveBucketTotal(
resp: ListIssuesCache,
from: IssueStatus,
to: IssueStatus,
): ListIssuesCache {
if (from === to) return resp;
const fromBucket = getBucket(resp, from);
const toBucket = getBucket(resp, to);
let next = setBucket(resp, from, {
...fromBucket,
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, to, { ...toBucket, total: toBucket.total + 1 });
return next;
}
/**
* Count-only reconcile for an issue BEYOND the loaded window that LEFT the
* list (reassigned / moved project): the bucket it was counted in loses one.
*/
export function decrementBucketTotal(
resp: ListIssuesCache,
status: IssueStatus,
): ListIssuesCache {
const bucket = getBucket(resp, status);
return setBucket(resp, status, {
...bucket,
total: Math.max(0, bucket.total - 1),
});
}
/**
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
* array; the input is not mutated.
*
* Inserting at the right slot (instead of appending to the end) is what keeps an
* optimistic move from snapping: the card lands where it will be after the
* server confirms, so no later cache refresh teleports it to the column tail.
*/
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
const idx = issues.findIndex((i) => i.position > issue.position);
if (idx === -1) return [...issues, issue];
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
}
/**
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
* current bucket, the issue moves to the new bucket and both buckets' totals
* are adjusted. The moved card — and a same-column card whose `position`
* changed — is re-inserted at its `position`-sorted slot rather than appended,
* so the cache order stays consistent with what the board renders. A plain
* field update (no status/position change) keeps the card in place.
*/
export function patchIssueInBuckets(
resp: ListIssuesCache,
id: string,
patch: Partial<Issue>,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const merged: Issue = { ...loc.issue, ...patch };
const nextStatus = patch.status ?? loc.status;
if (nextStatus === loc.status) {
const bucket = getBucket(resp, loc.status);
const positionChanged =
patch.position !== undefined && patch.position !== loc.issue.position;
if (!positionChanged) {
// Plain field update (labels, metadata, title, …): keep the slot so a
// remote edit never reorders an otherwise-untouched column.
return setBucket(resp, loc.status, {
...bucket,
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
// Same-column reorder: lift the card out and re-insert at its new slot.
return setBucket(resp, loc.status, {
...bucket,
issues: insertByPosition(
bucket.issues.filter((i) => i.id !== id),
merged,
),
});
}
const fromBucket = getBucket(resp, loc.status);
const toBucket = getBucket(resp, nextStatus);
let next = setBucket(resp, loc.status, {
issues: fromBucket.issues.filter((i) => i.id !== id),
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, nextStatus, {
issues: insertByPosition(toBucket.issues, merged),
total: toBucket.total + 1,
});
return next;
}