Files
multica/packages/core/issues/surface/membership.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

133 lines
4.7 KiB
TypeScript

import type { Issue, UpdateIssueRequest } from "../../types";
import type { MyIssuesFilter } from "../queries";
/**
* Whether an issue belongs to a filtered list cache.
*
* `"unknown"` means the client cannot decide from the entity alone — the
* filter's predicate lives server-side (agent-ownership graph behind
* `involves_user_id`, the my:all relation union) or the entity is missing the
* field the filter keys on. Callers patch on `true`, remove on `false`, and
* patch + invalidate on `"unknown"` — the "certain → patch, uncertain →
* invalidate" rule applied to list membership.
*/
export type IssueMembership = true | false | "unknown";
/**
* The field groups a write can touch that move an issue in or out of a
* filtered list (assignee / project) or shift per-status bucket totals
* (status). Creator is not here: it is immutable after create.
*/
export interface IssueChangedDims {
assignee: boolean;
project: boolean;
status: boolean;
}
/**
* Derive the changed dimensions from a mutation patch. `base` (the freshest
* cached pre-write entity, usually the detail cache) sharpens the answer:
* writing the same value an issue already has changes nothing. Without a
* base, any written membership field counts as changed — conservative, at
* worst one extra list refetch on settle.
*/
export function issueChangedDims(
patch: Partial<Issue> | UpdateIssueRequest,
base?: Issue,
): IssueChangedDims {
const has = (field: string) =>
Object.prototype.hasOwnProperty.call(patch, field);
const p = patch as Partial<Issue>;
return {
assignee:
(has("assignee_id") && (!base || base.assignee_id !== p.assignee_id)) ||
(has("assignee_type") && (!base || base.assignee_type !== p.assignee_type)),
project: has("project_id") && (!base || base.project_id !== p.project_id),
status: has("status") && p.status !== undefined && (!base || base.status !== p.status),
};
}
/**
* Does this list's server contract depend on any of the changed dimensions?
* `scope` is the myList scope segment from the query key (`undefined` for the
* unfiltered workspace list). When this returns false the write cannot move
* the issue in or out of the list, so a plain field patch is a complete
* reconcile.
*/
export function listFilterDependsOn(
scope: string | undefined,
filter: MyIssuesFilter,
changed: IssueChangedDims,
): boolean {
// my:all is the union of assigned / created / involved — the assigned and
// involved legs key on the assignee.
if (scope === "all") return changed.assignee;
if (
changed.assignee &&
(filter.assignee_id !== undefined ||
filter.assignee_ids !== undefined ||
filter.assignee_types !== undefined ||
filter.involves_user_id !== undefined)
) {
return true;
}
if (changed.project && filter.project_id !== undefined) return true;
// creator_id filters never react to updates — creator is immutable.
return false;
}
/**
* Judge an issue against a list's server contract. AND semantics across
* filter fields: any definitive miss is `false`; a predicate the client
* cannot evaluate (or a field the partial entity is missing) degrades the
* answer to `"unknown"` instead of guessing.
*/
export function issueMatchesListFilter(
issue: Partial<Issue>,
scope: string | undefined,
filter: MyIssuesFilter,
): IssueMembership {
// my:all — union across relations; the involved leg needs the server's
// agent-ownership graph, so membership is never decidable client-side.
if (scope === "all") return "unknown";
let unknown = false;
if (filter.assignee_id !== undefined) {
if (issue.assignee_id === undefined) unknown = true;
else if (issue.assignee_id !== filter.assignee_id) return false;
}
if (filter.assignee_ids !== undefined) {
if (issue.assignee_id === undefined) unknown = true;
else if (
issue.assignee_id === null ||
!filter.assignee_ids.includes(issue.assignee_id)
) {
return false;
}
}
if (filter.assignee_types !== undefined) {
if (issue.assignee_type === undefined) unknown = true;
else if (
issue.assignee_type === null ||
!filter.assignee_types.includes(issue.assignee_type)
) {
return false;
}
}
if (filter.creator_id !== undefined) {
if (issue.creator_id === undefined) unknown = true;
else if (issue.creator_id !== filter.creator_id) return false;
}
if (filter.project_id !== undefined) {
if (issue.project_id === undefined) unknown = true;
else if (issue.project_id !== filter.project_id) return false;
}
if (filter.involves_user_id !== undefined) {
// Indirect-assignee predicate (owned agents / squads) — server-only.
unknown = true;
}
return unknown ? "unknown" : true;
}