Commit Graph

12 Commits

Author SHA1 Message Date
Lambda
d49d309489 Merge remote-tracking branch 'origin/main' into agent/lambda/768b92e0
# Conflicts:
#	packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
2026-07-15 11:26:40 +08:00
Lambda
c402b91c1d fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
  and value writes now serialize config-vs-value and cap-vs-insert races
  (workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.

Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
  only the touched key against the current bag (concurrent WS writes to
  other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
  snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
  until the catalog query settles (cold cache no longer strips them).

Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 11:20:14 +08:00
Lambda
fceb9e90df feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).

- New `properties` query param on ListIssues and ListGroupedIssues:
  JSON {definitionId: [values]} compiled to an AND-of-ORs containment
  check (double NOT EXISTS over jsonb_array_elements). One value expands
  to every storage shape it could match — string (select), array element
  (multi_select), boolean (checkbox) — so the handler stays type-agnostic.
  Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
  typed expression (numeric CASE cast for number, NULLIF text for
  date/text/url); missing values sort last in both directions. Malformed
  ids 400; unknown/archived definitions degrade to position order instead
  of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
  window bag, so every surface (workspace + my-issues variants), query
  key, and per-status load-more page carries them automatically. The
  client-side re-sort layer is gone; applyIssueFilters keeps its property
  predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
  returned by a filtered 50-row page, plus sort order/missing-last,
  AND-across-definitions, and the 400/fallback sort paths.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 22:12:44 +08:00
Naiyuan Qing
c10bfa8f56 Revert "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit 40da795f6c.
2026-07-14 17:41:12 +08:00
Lambda
ea9a470343 fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
  value sort last in BOTH directions (the whole-array reverse flipped them
  to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
  into the No-value column instead of vanishing (unmatched column ids
  dropped the issue entirely). Defense-in-depth behind the new server-side
  in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
  definitions are stripped before reaching the filter predicates, and a
  persisted property sort on a non-active definition degrades to manual
  order — previously both kept silently applying while the header claimed
  otherwise. The filter badge counts only active-definition filters.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:30:14 +08:00
Lambda
784d9dd08a feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:

- Filter: per-definition sections in the Filter dropdown (select /
  multi_select options with color dots and counts; checkbox as Yes/No
  pseudo-options). OR within a definition, AND across definitions;
  client-side in applyIssueFilters, mirrored into filterAssigneeGroups
  for the assignee-grouped board. Included in active-filter count and
  Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
  chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
  Server keeps position order (fixed sort enum); the surface controller
  re-sorts client-side, swimlane/gantt reuse the same comparator.
  Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
  definitions — one column per option (definition order) plus a
  trailing No-value column, option-colored headings. Drag-drop moves
  position via UpdateIssue and applies the value through
  useSetIssueProperty/useUnsetIssueProperty (properties are not part
  of UpdateIssueRequest). Stale persisted property groupings fall back
  to status columns.

View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:30:14 +08:00
Lambda
645ea20b00 feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
  (list with type badges/option chips/usage counts, create/edit dialog
  with option editor, archive/restore, 20-cap indicator). Admin-gated;
  members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
  props' progressive disclosure — set values render as rows with
  type-appropriate editors (select/multi-select pickers, calendar,
  yes/no, inline input for text/number/url), unset ones live in the
  same '+ Add property' menu behind a separator. Archived definitions
  render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
  compat), api client methods, React Query hooks with optimistic
  single-key value writes, ws-updaters + realtime wiring for
  property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:29:00 +08:00
Naiyuan Qing
40da795f6c perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#5349)
* perf(inbox): virtualize notification list (MUL-4474)

The inbox notification list rendered every item at once. Each row mounts an
avatar + hover card, so a long inbox inflates the tab-switch commit — the
same render-amplifier class this issue targets.

Extract an InboxList component that virtualizes the rows via react-virtuoso
(customScrollParent over the existing overflow-y-auto element, same pattern
as the issue-detail timeline). Only the visible window plus a small overscan
is mounted; everything else — selection, hover, archive, scroll semantics,
the row component and callbacks — is unchanged. Virtualization changes
exactly one thing: whether an off-screen row is in the DOM.

Slice 2a of MUL-4474 (inbox is the no-DnD surface, done first to prove the
Virtuoso + scroll + keyboard harness before the drag surfaces). Draft: must
pass the manual zero-functional-change pass on a real Desktop build before
merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(board): virtualize board columns (MUL-4474)

Each board column rendered every card at once; cards carry pickers, avatars,
and a per-issue activity indicator, so a tall column inflates the tab-switch
commit. Virtualize the cards within each column via react-virtuoso, using the
column's own scroll container as customScrollParent.

The dnd-kit droppable stays on the always-mounted column scroll container
(merged callback ref feeds both dnd-kit and Virtuoso), and SortableContext
still wraps the full id list. So cross-column drops (status/assignee change)
and reorder among on-screen cards are unchanged; reordering to an off-screen
target relies on drag auto-scroll to mount it — the documented virtualization
tradeoff, to be confirmed in the manual pass. The infinite-scroll sentinel
rides Virtuoso's Footer slot so loadMore still fires at the bottom, and a
per-item pt-2 reproduces the previous space-y-2 gap with padding inside the
measured item box.

issues-page.test.tsx: mock react-virtuoso to render items inline (jsdom has no
layout), and make the useDroppable mock's setNodeRef referentially stable to
match real dnd-kit — the board's merged customScrollParent ref would otherwise
loop on a fresh ref each render.

Slice 2b of MUL-4474 on the shared inbox/list/board/swimlane branch. Draft:
requires the manual zero-functional-change pass on a real Desktop build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(list): virtualize issue list rows (MUL-4474)

The status-grouped list rendered every row in every expanded section at once;
each row carries a sortable, context menu, tooltip, and activity indicator, so
a long list inflates the tab-switch commit. Virtualize each expanded section's
rows with react-virtuoso, all instances sharing the page's single scroll
container as customScrollParent.

Everything structural is preserved by construction: the Base UI accordion,
sticky status headers, collapse, the per-section useDroppable, the per-section
SortableContext, and the load-more sentinel (now Virtuoso's Footer). The
Virtuoso only mounts for an expanded section (a collapsed/hidden panel has no
viewport to measure). Virtualization changes exactly one thing: whether an
off-screen row is in the DOM.

issue-surface.test.tsx: mock react-virtuoso inline (jsdom has no layout) so the
surface-level loading-semantics assertions still observe the list's rows.

Slice 2c of MUL-4474 on the shared branch. Draft: requires the manual
zero-functional-change pass on a real Desktop build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(swimlane): virtualize lanes (MUL-4474)

The swimlane rendered every lane (each a full row of status cells) at once.
Virtualize the vertical lane axis with react-virtuoso over the board's outer
scroll box (customScrollParent), so only on-screen lanes stay mounted.

Behavior is preserved: pinned lanes keep their leading position, the
SortableContext still wraps the lane set for grip-drag reorder (its items are
only the non-pinned lane ids), per-cell droppables and per-cell card
SortableContexts are unchanged (cells live on mounted lanes), the sticky status
header stays above the list, and the per-status load-more sentinels ride
Virtuoso's Footer. pt-4 per lane reproduces the previous gap-4.

swimlane-view.test.tsx: mock react-virtuoso inline so the ~47 lane/cell/DnD
assertions still see the lanes the virtualized list renders.

Slice 2d of MUL-4474 on the shared branch — this completes the four surfaces
(inbox/board/list/swimlane). Draft: requires the full manual
zero-functional-change pass on a real Desktop build before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): don't pass undefined to Virtuoso `components` (MUL-4474)

react-virtuoso seeds its `components` prop with an internal `{}` default;
passing `components={undefined}` (which the list and board did when there was
no Footer — hasMore false / no column footer) overwrites that default with
undefined, so Virtuoso's startup destructure of `EmptyPlaceholder`/`Footer`
throws and the surface crashes. jsdom tests mock react-virtuoso so this only
surfaced on a real Desktop build (found in manual perf testing).

Return a stable module-level empty object instead of undefined. Inbox (omits
the prop entirely) and swimlane (always supplies a Footer) never hit this and
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 16:34:30 +08:00
Naiyuan Qing
3a493ab417 perf(issues): de-amplify per-row agent activity indicator (MUL-4474) (#5338)
Each issue row's IssueAgentActivityIndicator subscribed to the whole
workspace agent-task snapshot via useQuery. Any task change swaps the
snapshot array reference, so every observing row re-rendered — on a busy
workspace the snapshot changes constantly, turning one task update into a
full-list re-render and inflating the tab-switch commit.

Narrow each row's subscription to this issue's tasks with a `select`
(selectIssueTasks). React Query's structural sharing keeps the selected
value referentially stable when the issue's own tasks are unchanged, so a
snapshot invalidation now only re-renders the rows whose tasks actually
moved.

This is slice 1 of MUL-4474 (render de-amplification). Virtualization of
list/board/swimlane/inbox and the non-position useSortable mount change
are tracked separately — they need interactive drag + DevTools Performance
verification that the headless runtime can't provide.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 14:58:58 +08:00
Naiyuan Qing
0582db356e feat(issues): make cancelled a default status, not filter-gated (MUL-4290) (#5135)
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.

- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
  this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
  default visible/hidden status derivation, the assignee-grouped board's
  default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
  surface data layer. Cancelled flows through to list/board/swimlane columns,
  header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
  board show/hide controls consistently (hideStatus already used ALL_STATUSES).

The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.

Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 11:10:24 +08:00
Naiyuan Qing
6077f1a9b0 feat(issues): surface cancelled issues via status filter (MUL-4261) (#5099)
* feat(issues): surface cancelled issues via status filter (MUL-4261)

Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.

Implement plan A (fetch-always, hide-by-default):

- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
  into the byStatus cache and rebuckets correctly when an issue is
  cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
  default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
  issues are excluded from `surfaceIssues` (and therefore list/board/
  swimlane columns, header facet counts, batch selection, and isEmpty)
  unless the filter explicitly selects "cancelled". Then a Cancelled
  section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
  hideable/persistent board column.

Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.

Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261)

The swimlane derived its status columns as
`BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing
canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES
omits `cancelled`, a filter-selected Cancelled column was silently dropped
even though the controller's `visibleStatuses` included it — the surface
fetched and gated cancelled correctly, but swimlane never rendered it.

Filter against ALL_STATUSES instead: same canonical ordering, but a
selected `cancelled` column now survives. `hiddenStatuses` stays
board-only, so cancelled is still never a hideable/persistent column.

Regression tests:
- swimlane renders a Cancelled column + its cards when cancelled is in
  visibleStatuses, and omits it otherwise (verified failing pre-fix);
- controller asserts hiddenStatuses never contains cancelled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 10:07:34 +08:00
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