mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* 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>
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
import type {
|
|
InfiniteData,
|
|
QueryClient,
|
|
QueryKey,
|
|
} from "@tanstack/react-query";
|
|
import {
|
|
agentActivityKeys,
|
|
agentRunCountsKeys,
|
|
agentTaskSnapshotKeys,
|
|
agentTasksKeys,
|
|
} from "../agents/queries";
|
|
import { labelKeys } from "../labels/queries";
|
|
import type {
|
|
Issue,
|
|
ListIssuesCache,
|
|
ListIssuesResponse,
|
|
} from "../types";
|
|
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
|
import { issueKeys } from "./queries";
|
|
import { useRecentIssuesStore } from "./stores/recent-issues-store";
|
|
|
|
export type DeletedIssueCacheMetadata = {
|
|
parentIssueIds: string[];
|
|
};
|
|
|
|
function collectParentId(
|
|
parentIssueIds: Set<string>,
|
|
parentId: string | null | undefined,
|
|
) {
|
|
if (parentId) parentIssueIds.add(parentId);
|
|
}
|
|
|
|
function collectParentFromListCache(
|
|
parentIssueIds: Set<string>,
|
|
data: ListIssuesCache | undefined,
|
|
issueId: string,
|
|
) {
|
|
const parentId = data
|
|
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
|
: undefined;
|
|
collectParentId(parentIssueIds, parentId);
|
|
}
|
|
|
|
function parentIdFromChildrenKey(key: QueryKey) {
|
|
const parentId = key[key.length - 1];
|
|
return typeof parentId === "string" ? parentId : null;
|
|
}
|
|
|
|
export function collectDeletedIssueCacheMetadata(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
): DeletedIssueCacheMetadata {
|
|
const parentIssueIds = new Set<string>();
|
|
|
|
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
|
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
|
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.list(wsId),
|
|
})) {
|
|
collectParentFromListCache(parentIssueIds, data, issueId);
|
|
}
|
|
|
|
for (const [, data] of qc.getQueriesData<
|
|
InfiniteData<ListIssuesResponse, number>
|
|
>({ queryKey: issueKeys.flatAll(wsId) })) {
|
|
for (const page of data?.pages ?? []) {
|
|
collectParentId(
|
|
parentIssueIds,
|
|
page.issues.find((issue) => issue.id === issueId)?.parent_issue_id,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
})) {
|
|
collectParentFromListCache(parentIssueIds, data, issueId);
|
|
}
|
|
|
|
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
|
queryKey: [...issueKeys.all(wsId), "children"],
|
|
})) {
|
|
const child = data?.find((issue) => issue.id === issueId);
|
|
if (!child) continue;
|
|
collectParentId(parentIssueIds, child.parent_issue_id);
|
|
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
|
}
|
|
|
|
return { parentIssueIds: Array.from(parentIssueIds) };
|
|
}
|
|
|
|
export function pruneDeletedIssueFromListCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.list(wsId),
|
|
})) {
|
|
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
|
old ? removeIssueFromBuckets(old, issueId) : old,
|
|
);
|
|
}
|
|
|
|
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
})) {
|
|
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
|
old ? removeIssueFromBuckets(old, issueId) : old,
|
|
);
|
|
}
|
|
|
|
for (const [key, data] of qc.getQueriesData<
|
|
InfiniteData<ListIssuesResponse, number>
|
|
>({ queryKey: issueKeys.flatAll(wsId) })) {
|
|
if (!data?.pages) continue;
|
|
const found = data.pages.some((page) =>
|
|
page.issues.some((issue) => issue.id === issueId),
|
|
);
|
|
if (!found) continue;
|
|
qc.setQueryData<InfiniteData<ListIssuesResponse, number>>(key, {
|
|
...data,
|
|
pages: data.pages.map((page) => ({
|
|
...page,
|
|
total: Math.max(0, page.total - 1),
|
|
issues: page.issues.filter((issue) => issue.id !== issueId),
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
|
|
export function pruneDeletedIssueFromParentChildrenCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
|
old?.filter((issue) => issue.id !== issueId),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function invalidateDeletedIssueParentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
if (metadata.parentIssueIds.length === 0) return;
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
|
}
|
|
|
|
export function invalidateDeletedIssueDependentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
|
}
|
|
|
|
export function invalidateIssueScopedCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
}
|
|
|
|
export function cleanupDeletedIssueCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
|
) {
|
|
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
|
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
|
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
|
|
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) });
|
|
// Project Gantt cache lives outside `myAll`, so it needs an explicit
|
|
// refresh when an issue is removed — the deleted row may have been a
|
|
// scheduled bar visible right now.
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
invalidateDeletedIssueDependentCaches(qc, wsId);
|
|
|
|
// Recent Issues store persists to localStorage and survives reloads, so a
|
|
// deleted id left behind keeps the Cmd+K command bar firing 404s on every
|
|
// open. Both the delete mutation and the WS delete event flow through here,
|
|
// so a single call covers self-delete and cross-client delete.
|
|
useRecentIssuesStore.getState().forgetIssue(wsId, issueId);
|
|
}
|