Files
multica/packages/views/issues/surface/selection-context.tsx
Jiayuan Zhang 002ea0d879 MUL-4797: add configurable issue table view (#5454)
* feat(issues): add configurable table view

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

* test(issues): cover table columns in page fixture

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

* fix(issues): make table column picker interactive

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

* fix(issues): repair quick create and virtualize table rows

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

* fix(issues): keep pinned table cells opaque

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

* fix(issues): anchor full-width table rows

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

* fix(issues): consolidate table controls

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

* fix(issues): harden table pagination and export

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

* feat(issues): add table quick search

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

* fix(issues): make table window filters, selection, and export authoritative

Round-2 review fixes for the issues table (MUL-4797):

- Send the agents-working filter as a server ids facet so matches on
  unfetched pages surface and total/pagination/export agree; a present-
  but-empty id list yields an empty window instead of an unfiltered one.
- Reset surface selection when the membership window changes and act on
  selection ∩ visible rows in the batch toolbar, so batch actions,
  Export selected, and the count all share one authoritative set.
- Materialize the full flat window while table grouping is active, and
  suspend hierarchy nesting / parent-based grouping until the window is
  complete so structure cannot reshuffle as pages arrive; suppress
  header facet-count badges while the table window is partial.
- Resolve actor directories and the property catalog at export time and
  fail the export instead of writing Unknown* actors or dropping
  configured property columns on cold/errored lookups.
- Append a unique id tie-break to the list/grouped ORDER BY and mirror
  it in compareIssuesForSort so offset pages are stable across
  same-timestamp ties.

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

* fix(issues): bound table structure window and align chip/transport/selection

Round-3 review fixes for the issues table (MUL-4797):

- Cap whole-window materialization at TABLE_STRUCTURE_MAX_WINDOW (1000):
  below it the remaining pages load automatically — hierarchy applies
  without scrolling to the last page — and above it grouping/hierarchy
  suspend with an explicit toolbar notice instead of triggering an
  unbounded workspace download from a persisted view option.
- Give the agents-working chip the authoritative in-window running set
  (the ids-facet window query, shared key with the filter-on state) so
  its badge can no longer say 0 while the filter would find matches on
  unfetched pages; falls back to loaded-row scoping elsewhere.
- Route ids-facet windows through a new POST /api/issues/query twin —
  hundreds of running-issue UUIDs overflow the ~8 KB GET request-line
  budget of common proxies. The body carries the same key/value pairs;
  the handler rebuilds the query string and delegates to ListIssues.
- Reset surface selection during render (key-change pattern) instead of
  a post-commit effect, so no frame ever pairs new membership with the
  old selection.

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

* fix(issues): harden table auto-pagination against errors and stale totals

Round-4 review fixes for the issues table (MUL-4797):

- Stop the structure materialization loop (and the scroll sentinel) when
  the window query is in error state — a persistently failing page left
  hasNextPage true and isFetchingNextPage false after every attempt, so
  the ungated effect refired forever. Resuming is an explicit toolbar
  Retry. The advancement decision now lives in a pure, tested
  shouldAutoLoadNextStructurePage helper.
- Make the structure ceiling a hard stop: the ceiling check reads the
  LATEST page's total (pagination already advances on it, so a stale
  small page-1 total could re-open unbounded materialization), and the
  loop additionally halts on loaded count >= ceiling regardless of any
  reported total.
- Drive the working (ids-facet) window to completion — it is inherently
  bounded by the running set — and treat it as the chip's authoritative
  scope only when complete, so >100 running issues no longer under-count
  as a single page.

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

* fix(issues): make working-window pagination capped and unknown-aware

Round-5 (final) review fixes for the issues table (MUL-4797):

- The working (ids-facet) window now advances through the same
  shouldAutoLoadNextWindowPage gates as the structure loop — it shares
  the main table's cache key while the agents-working filter is on, so
  an uncapped chip-driven loop re-opened the very ceiling the table just
  enforced. An over-ceiling window stops after page one.
- A cold-load failure of the flat window is an ERROR state, not an empty
  workspace: isEmpty only claims empty on a successful zero-result
  fetch, and the surface renders a dedicated failed-to-load state with a
  reachable Retry (the in-table Retry never mounted without data).
- The chip scope is now tri-state honest: a COMPLETE window (or an empty
  running set) yields a precise count, keepPreviousData carries the
  last-known-complete set across re-keys, and everything else — cold
  resolving, failed, over the ceiling — presents as an explicit unknown
  ('Agents working: —') instead of a number derived from whichever
  incomplete window happened to be loaded.

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

* fix(issues): single pagination owner and placeholder-honest chip scope

Round-6 review fixes for the issues table (MUL-4797):

- Exclude placeholder data from the working-window completeness gate:
  on a re-key (running set or facet change) keepPreviousData leaves the
  OLD key's rows visible, and pairing them with the new task snapshot
  published a precise-looking number for a scope nobody fetched. The
  scope now reads unknown until the new key resolves.
- Make the shared table query single-owner while the agents-working
  filter is on: the chip's background loop no longer answers the same
  render snapshot as TableView's structure loop, and every auto caller
  (structure loop, working loop, scroll sentinel, retry) now uses
  fetchNextPage({cancelRefetch: false}) so a concurrent responder
  no-ops instead of cancel/restarting a fetch whose HTTP request is not
  abortable — which had been duplicating every offset.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 19:21:27 +08:00

115 lines
3.3 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
export interface IssueSurfaceSelection {
selectedIds: Set<string>;
toggle: (id: string) => void;
select: (ids: string[]) => void;
deselect: (ids: string[]) => void;
clear: () => void;
}
const IssueSurfaceSelectionContext =
createContext<IssueSurfaceSelection | null>(null);
export function useCreateIssueSurfaceSelection(
surfaceKey: string,
resetKey = surfaceKey,
): IssueSurfaceSelection {
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
const [committedResetKey, setCommittedResetKey] = useState(resetKey);
// Render-phase reset (the React "adjusting state when props change"
// pattern), NOT an effect: an effect runs after commit, so one frame would
// ship the NEW membership (filters/search) with the OLD selection — batch
// consumers could act on rows the window no longer contains during that
// gap. Setting state during render re-renders synchronously before commit,
// so no such frame ever becomes visible. The size guard keeps the common
// nothing-selected key change from allocating a new Set and re-rendering
// the surface.
if (committedResetKey !== resetKey) {
setCommittedResetKey(resetKey);
if (selectedIds.size > 0) setSelectedIds(new Set());
}
const toggle = useCallback((id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const select = useCallback((ids: string[]) => {
setSelectedIds((current) => {
const next = new Set(current);
for (const id of ids) next.add(id);
return next;
});
}, []);
const deselect = useCallback((ids: string[]) => {
setSelectedIds((current) => {
const next = new Set(current);
for (const id of ids) next.delete(id);
return next;
});
}, []);
const clear = useCallback(() => {
setSelectedIds(new Set());
}, []);
return useMemo(
() => ({ selectedIds, toggle, select, deselect, clear }),
[clear, deselect, select, selectedIds, toggle],
);
}
export function IssueSurfaceSelectionProvider({
selection,
children,
}: {
selection: IssueSurfaceSelection;
children: ReactNode;
}) {
return (
<IssueSurfaceSelectionContext.Provider value={selection}>
{children}
</IssueSurfaceSelectionContext.Provider>
);
}
export function useIssueSurfaceSelectionOptional() {
return useContext(IssueSurfaceSelectionContext);
}
export function useIssueSurfaceSelection(): IssueSurfaceSelection {
const surfaceSelection = useIssueSurfaceSelectionOptional();
const globalSelectedIds = useIssueSelectionStore((s) => s.selectedIds);
const globalToggle = useIssueSelectionStore((s) => s.toggle);
const globalSelect = useIssueSelectionStore((s) => s.select);
const globalDeselect = useIssueSelectionStore((s) => s.deselect);
const globalClear = useIssueSelectionStore((s) => s.clear);
return (
surfaceSelection ?? {
selectedIds: globalSelectedIds,
toggle: globalToggle,
select: globalSelect,
deselect: globalDeselect,
clear: globalClear,
}
);
}