mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
133 lines
4.7 KiB
TypeScript
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;
|
|
}
|