mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* fix(issues): count agents working in the surface, not the workspace (MUL-5525) The "N agents working" chip ran its own workspace-wide `/api/working-agents` read while the list it filters came from the surface's own compiled query. Two definitions of the same question, so on a project page the chip could advertise agents working nowhere near that project and then open an empty list. Every other narrowing the list knows about — status, priority, assignee, creator, label, custom property, date, sub-issue display, the /issues Members/Agents tabs — was invisible to the count for the same reason. Only /my-issues (relation) and the issue-detail sub-issue chip (parent) were narrowed, because those were the two cases the endpoint had grown parameters for. Rather than add a `project_id` parameter and leave the next dimension to be discovered the same way, the count now comes from a `working_agents` facet on the existing issue-table facets endpoint: same scope, same filters, same compiled WHERE clause the rows come from, joined to running issue tasks and grouped by agent. Correct-by-construction instead of correct-by-keeping-two-lists-in-sync. - Facet is disjunctive like every other one: it drops `working_issue_ids` / `working_only`, so the answer is identical whether the filter is on or off and the number does not move when you click the chip. - Facet keys are agent ids, so they pass the same visibility gate as the other workspace-wide agent aggregations — a private or non-allow-listed agent is not disclosed by id, count, or presence. - Gantt keeps a client-side count: its canvas projection (scheduled + dated + showCompleted) cannot be expressed in the Table query spec, so it counts the agents holding canvas rows instead. - The chip is now presentational; `undefined` renders the existing indeterminate label rather than a zero it cannot stand behind. - Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count moved to the endpoint in MUL-5200, keeping only the Gantt branch that still has a real consumer. Also fixes the empty state that bug dropped you into: a filtered-empty surface claimed "No issues linked — create one" while 41 issues sat behind the filter. Shared filtered-empty state now precedes each surface's own copy and offers to clear exactly the filters it blames. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors), go test ./internal/handler (new facet tests cover project scope, status and sub-issue narrowing, filter-independence, and the access gate). Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(issues): keep the working-agents unknown state unknown (MUL-5525) The chip correctly refused to print a number for an unresolved projection, then handed the hover card `agents ?? []` — so hovering an indeterminate chip read "No agents working right now". That is the same unearned claim this issue is about, made by the one surface with room to spell it out: the label said "—" while the body next to it asserted zero. - `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] | undefined` and distinguishes all three states: `undefined` renders new `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a non-empty list keeps the roster. The chip passes its projection through untouched. - The colour tier had the same collapse: unknown wore the neutral tier WITH muted text, which is exactly the "nothing is happening here" tier a known zero wears. `chipAppearance` now takes a `ChipActivity` ("unknown" | "none" | "some") instead of a boolean, so the three cases cannot be written as two, and unknown stays neutral but undimmed. - The sub-issues chip is unaffected: it passes a resolved array and renders nothing at zero, so it never claimed anything either way. Regression tests cover the hover path specifically — reverting either downgrade fails "does not let the hover body downgrade an unresolved projection to zero", "does not dim the chip while the projection is unresolved", and the chipAppearance unknown case (verified by reverting). `WorkingAgentsHoverContent` also gets direct unknown / empty / roster tests, and `chipActivity` one for the three-way split. Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
540 lines
17 KiB
TypeScript
540 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import {
|
|
useQuery,
|
|
type QueryKey,
|
|
} from "@tanstack/react-query";
|
|
import type { Issue, IssueAssigneeGroup, Project } from "@multica/core/types";
|
|
import { ALL_STATUSES } from "@multica/core/issues/config";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import {
|
|
childIssueProgressOptions,
|
|
type AssigneeGroupedIssuesFilter,
|
|
type IssueSortParam,
|
|
type MyIssuesFilter,
|
|
} from "@multica/core/issues/queries";
|
|
import {
|
|
issueSurfaceAssigneeGroupsOptions,
|
|
issueSurfaceGanttOptions,
|
|
issueSurfaceListOptions,
|
|
} from "@multica/core/issues/surface/repository";
|
|
import type { IssueSurfaceQueryPlan } from "@multica/core/issues/surface/query-plan";
|
|
import type { IssueStatus } from "@multica/core/types";
|
|
import {
|
|
applyIssueFilters,
|
|
filterAssigneeGroups,
|
|
type IssueFilterState,
|
|
type IssueFilters,
|
|
} from "../utils/filter";
|
|
import type { ChildProgress } from "../components/list-row";
|
|
import type {
|
|
IssueStatusBranches,
|
|
IssueStatusPagination,
|
|
} from "./use-issue-status-branches";
|
|
import type { IssueGroupBranches } from "./use-issue-group-branches";
|
|
|
|
const EMPTY_ISSUES: Issue[] = [];
|
|
const EMPTY_CHILD_PROGRESS = new Map<string, ChildProgress>();
|
|
const EMPTY_PROJECTS: Project[] = [];
|
|
|
|
/**
|
|
* The rows the gantt canvas actually draws, on top of the shared filters.
|
|
*
|
|
* The canvas adds two rules of its own: a row needs a date to be placed, and
|
|
* completed work is hidden unless the user asks for it. The data source only
|
|
* delivers scheduled issues (server-side `scheduled=true`), but a row can
|
|
* still arrive without a date — e.g. a WS-driven optimistic patch that just
|
|
* cleared start_date / due_date and is waiting for the cache to refetch — so
|
|
* the date check stays defensive.
|
|
*
|
|
* These rules live HERE rather than privately inside GanttView so the header
|
|
* chip can narrow the same set the canvas draws. A view that filters its own
|
|
* rows in secret is exactly how the chip's count drifted from the list in the
|
|
* first place (MUL-4884); duplicating the rules in both places would just
|
|
* reintroduce the drift with extra steps.
|
|
*/
|
|
function ganttCanvasRows(issues: Issue[], showCompleted: boolean): Issue[] {
|
|
const dated = issues.filter((i) => i.start_date || i.due_date);
|
|
if (showCompleted) return dated;
|
|
return dated.filter((i) => i.status !== "done" && i.status !== "cancelled");
|
|
}
|
|
|
|
export interface IssueSurfaceData {
|
|
surfaceIssues: Issue[];
|
|
projectIssues: Issue[];
|
|
issues: Issue[];
|
|
swimlaneIssues: Issue[];
|
|
/** Gantt only: the canvas rows the agents-working filter would leave on
|
|
* screen. `undefined` on every other view mode, where the header chip
|
|
* sources its count from the `working_agents` server facet instead. */
|
|
ganttWorkingScopeIssues: Issue[] | undefined;
|
|
filteredGanttIssues: Issue[];
|
|
assigneeGroups?: IssueAssigneeGroup[];
|
|
assigneeGroupQueryKey?: QueryKey;
|
|
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
|
|
filter: MyIssuesFilter;
|
|
loadMoreScope?: string;
|
|
loadMoreFilter?: MyIssuesFilter;
|
|
ganttIssues: Issue[];
|
|
visibleStatuses: IssueStatus[];
|
|
hiddenStatuses: IssueStatus[];
|
|
statusPagination: IssueStatusPagination;
|
|
activeFilters: Omit<IssueFilters, "statusFilters">;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
projectMap: Map<string, Project>;
|
|
resolveTableExportLookups: (needs: {
|
|
projects: boolean;
|
|
childProgress: boolean;
|
|
}) => Promise<{
|
|
projectMap: Map<string, Project>;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
}>;
|
|
isLoading: boolean;
|
|
/** The window's data is being revalidated while the previous snapshot is
|
|
* shown as a placeholder (sort/date change, or any grouped-board filter
|
|
* change). Drives the header's deferred refresh indicator — content stays
|
|
* put, so this is NOT a loading state. */
|
|
isRefreshing: boolean;
|
|
isEmpty: boolean;
|
|
}
|
|
|
|
export function useIssueSurfaceData({
|
|
wsId,
|
|
queryPlan,
|
|
projectId,
|
|
usesAssigneeBoard,
|
|
usesGantt,
|
|
usesTable,
|
|
serverStatusBranches,
|
|
serverGroupBranches,
|
|
ganttShowCompleted,
|
|
sort,
|
|
statusFilters,
|
|
priorityFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
agentRunningFilter,
|
|
creatorFilters,
|
|
projectFilters,
|
|
includeNoProject,
|
|
labelFilters,
|
|
propertyFilters,
|
|
workingIssueIDs,
|
|
showSubIssues,
|
|
loadProjects,
|
|
}: {
|
|
wsId: string;
|
|
queryPlan: IssueSurfaceQueryPlan;
|
|
projectId?: string;
|
|
usesAssigneeBoard: boolean;
|
|
usesGantt: boolean;
|
|
usesTable: boolean;
|
|
serverStatusBranches: IssueStatusBranches;
|
|
serverGroupBranches: IssueGroupBranches;
|
|
/** Gantt's "show completed" display toggle. The canvas hides done/cancelled
|
|
* rows without it, so the working scope has to honour it too. */
|
|
ganttShowCompleted: boolean;
|
|
sort: IssueSortParam;
|
|
statusFilters: IssueStatus[];
|
|
priorityFilters: IssueFilterState["priorityFilters"];
|
|
assigneeFilters: IssueFilterState["assigneeFilters"];
|
|
includeNoAssignee: boolean;
|
|
agentRunningFilter: boolean;
|
|
creatorFilters: IssueFilterState["creatorFilters"];
|
|
projectFilters: string[];
|
|
includeNoProject: boolean;
|
|
labelFilters: string[];
|
|
propertyFilters: Record<string, string[]>;
|
|
/** Distinct running-task issue ids projected by `/api/working-agents`. */
|
|
workingIssueIDs: ReadonlySet<string>;
|
|
showSubIssues: boolean;
|
|
loadProjects: boolean;
|
|
}): IssueSurfaceData {
|
|
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(
|
|
() => ({
|
|
...queryPlan.groupedScopeFilter,
|
|
statuses: statusFilters.length > 0 ? statusFilters : [...ALL_STATUSES],
|
|
priorities: priorityFilters,
|
|
assignee_filters: assigneeFilters,
|
|
include_no_assignee: includeNoAssignee,
|
|
creator_filters: creatorFilters,
|
|
project_ids: projectFilters,
|
|
include_no_project: includeNoProject,
|
|
label_ids: labelFilters,
|
|
}),
|
|
[
|
|
assigneeFilters,
|
|
creatorFilters,
|
|
includeNoAssignee,
|
|
includeNoProject,
|
|
labelFilters,
|
|
priorityFilters,
|
|
projectFilters,
|
|
queryPlan.groupedScopeFilter,
|
|
statusFilters,
|
|
],
|
|
);
|
|
|
|
const activeAssigneeGroupsOptions = issueSurfaceAssigneeGroupsOptions(
|
|
wsId,
|
|
queryPlan,
|
|
assigneeGroupFilter,
|
|
sort,
|
|
);
|
|
|
|
const statusIssuesQuery = useQuery({
|
|
...issueSurfaceListOptions(wsId, queryPlan, sort),
|
|
enabled:
|
|
!usesAssigneeBoard &&
|
|
!usesGantt &&
|
|
!usesTable &&
|
|
!serverStatusBranches.enabled &&
|
|
!serverGroupBranches.enabled,
|
|
});
|
|
const assigneeGroupsQuery = useQuery({
|
|
...activeAssigneeGroupsOptions,
|
|
enabled: usesAssigneeBoard && !serverGroupBranches.enabled,
|
|
});
|
|
const ganttIssuesQuery = useQuery({
|
|
...issueSurfaceGanttOptions(wsId, projectId ?? ""),
|
|
enabled: usesGantt,
|
|
});
|
|
const workingFilterContext = useMemo(
|
|
() => ({ runningIssueIds: workingIssueIDs }),
|
|
[workingIssueIDs],
|
|
);
|
|
const bucketedIssues = useMemo(() => {
|
|
return serverStatusBranches.enabled
|
|
? serverStatusBranches.issues
|
|
: serverGroupBranches.enabled
|
|
? serverGroupBranches.issues
|
|
: usesAssigneeBoard
|
|
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
|
|
: (statusIssuesQuery.data ?? EMPTY_ISSUES);
|
|
}, [
|
|
assigneeGroupsQuery.data?.groups,
|
|
serverStatusBranches.enabled,
|
|
serverStatusBranches.issues,
|
|
serverGroupBranches.enabled,
|
|
serverGroupBranches.issues,
|
|
statusIssuesQuery.data,
|
|
usesAssigneeBoard,
|
|
]);
|
|
|
|
// `cancelled` is a first-class default status (MUL-4290): it is fetched into
|
|
// the cache like every other status and flows straight through to list /
|
|
// board / swimlane columns, header facet counts, batch selection, and the
|
|
// isEmpty check. The status filter narrows this set like any other status —
|
|
// it no longer unlocks an otherwise-hidden bucket.
|
|
const ganttIssues = ganttIssuesQuery.data ?? EMPTY_ISSUES;
|
|
const surfaceIssues = usesGantt
|
|
? ganttIssues
|
|
: usesTable
|
|
? EMPTY_ISSUES
|
|
: bucketedIssues;
|
|
|
|
const baseFilterState = useMemo<IssueFilterState>(
|
|
() => ({
|
|
statusFilters,
|
|
priorityFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
creatorFilters,
|
|
projectFilters,
|
|
includeNoProject,
|
|
labelFilters,
|
|
propertyFilters,
|
|
workingOnly: agentRunningFilter,
|
|
showSubIssues,
|
|
}),
|
|
[
|
|
assigneeFilters,
|
|
agentRunningFilter,
|
|
creatorFilters,
|
|
includeNoAssignee,
|
|
includeNoProject,
|
|
labelFilters,
|
|
priorityFilters,
|
|
projectFilters,
|
|
propertyFilters,
|
|
showSubIssues,
|
|
statusFilters,
|
|
],
|
|
);
|
|
|
|
const issues = useMemo(
|
|
() =>
|
|
serverStatusBranches.enabled
|
|
? surfaceIssues
|
|
: applyIssueFilters(
|
|
surfaceIssues,
|
|
baseFilterState,
|
|
workingFilterContext,
|
|
),
|
|
[
|
|
baseFilterState,
|
|
serverStatusBranches.enabled,
|
|
surfaceIssues,
|
|
workingFilterContext,
|
|
],
|
|
);
|
|
|
|
const statuslessFilterState = useMemo<IssueFilterState>(
|
|
() => ({
|
|
...baseFilterState,
|
|
statusFilters: [],
|
|
}),
|
|
[baseFilterState],
|
|
);
|
|
|
|
const swimlaneIssues = useMemo(
|
|
() =>
|
|
applyIssueFilters(
|
|
surfaceIssues,
|
|
statuslessFilterState,
|
|
workingFilterContext,
|
|
),
|
|
[statuslessFilterState, surfaceIssues, workingFilterContext],
|
|
);
|
|
|
|
const filteredGanttIssues = useMemo(
|
|
() =>
|
|
ganttCanvasRows(
|
|
applyIssueFilters(ganttIssues, baseFilterState, workingFilterContext),
|
|
ganttShowCompleted,
|
|
),
|
|
[
|
|
baseFilterState,
|
|
ganttIssues,
|
|
ganttShowCompleted,
|
|
workingFilterContext,
|
|
],
|
|
);
|
|
|
|
// The assignee-grouped board renders straight from `groups`, bypassing the
|
|
// flat applyIssueFilters output — re-apply the remaining client-only
|
|
// display filters per group. Server-owned group paths encode running-task
|
|
// membership in the canonical query; this fallback uses the same issue ids.
|
|
const filteredAssigneeGroups = useMemo(
|
|
() =>
|
|
filterAssigneeGroups(assigneeGroupsQuery.data?.groups, {
|
|
agentRunningFilter,
|
|
runningIssueIds: workingIssueIDs,
|
|
showSubIssues,
|
|
propertyFilters,
|
|
}),
|
|
[
|
|
assigneeGroupsQuery.data?.groups,
|
|
agentRunningFilter,
|
|
propertyFilters,
|
|
showSubIssues,
|
|
workingIssueIDs,
|
|
],
|
|
);
|
|
|
|
const workingFilterState = useMemo<IssueFilterState>(
|
|
() => ({
|
|
...baseFilterState,
|
|
workingOnly: true,
|
|
}),
|
|
[baseFilterState],
|
|
);
|
|
|
|
// The Gantt canvas rows the agents-working filter would leave on screen —
|
|
// i.e. exactly what you get when you click the header chip in Gantt.
|
|
//
|
|
// Gantt is the one surface whose membership is NOT server-owned: it draws
|
|
// from a fully materialized scheduled-issue window, and its canvas
|
|
// projection (scheduled + dated + showCompleted) cannot be expressed in the
|
|
// Table query spec. So the header chip cannot source its count from the
|
|
// `working_agents` server facet here, and derives it from this set instead.
|
|
//
|
|
// Every other view mode (list / board / swimlane / table) resolves the same
|
|
// question through that facet, against the identical scope + filters the
|
|
// rows come from. Rebuilding a second complete issue window client-side to
|
|
// decorate the chip is what the server facet exists to avoid.
|
|
const ganttWorkingScopeIssues = useMemo(() => {
|
|
if (!usesGantt) return undefined;
|
|
return ganttCanvasRows(
|
|
applyIssueFilters(ganttIssues, workingFilterState, workingFilterContext),
|
|
ganttShowCompleted,
|
|
);
|
|
}, [
|
|
ganttIssues,
|
|
ganttShowCompleted,
|
|
usesGantt,
|
|
workingFilterState,
|
|
workingFilterContext,
|
|
]);
|
|
|
|
const {
|
|
data: childProgressData,
|
|
refetch: refetchChildProgress,
|
|
} = useQuery(childIssueProgressOptions(wsId));
|
|
const childProgressMap = childProgressData ?? EMPTY_CHILD_PROGRESS;
|
|
const {
|
|
data: projectData,
|
|
refetch: refetchProjects,
|
|
} = useQuery({
|
|
...projectListOptions(wsId),
|
|
enabled: loadProjects,
|
|
});
|
|
const projects = projectData ?? EMPTY_PROJECTS;
|
|
const projectMap = useMemo(
|
|
() => new Map(projects.map((project) => [project.id, project])),
|
|
[projects],
|
|
);
|
|
const resolveTableExportLookups = useCallback(
|
|
async (needs: { projects: boolean; childProgress: boolean }) => {
|
|
const [projectResult, progressResult] = await Promise.all([
|
|
needs.projects ? refetchProjects() : Promise.resolve(null),
|
|
needs.childProgress
|
|
? refetchChildProgress()
|
|
: Promise.resolve(null),
|
|
]);
|
|
if (projectResult?.error) throw projectResult.error;
|
|
if (progressResult?.error) throw progressResult.error;
|
|
if (needs.projects && !projectResult?.data) {
|
|
throw new Error("Failed to load project data for export");
|
|
}
|
|
if (needs.childProgress && !progressResult?.data) {
|
|
throw new Error("Failed to load child progress for export");
|
|
}
|
|
const resolvedProjects = projectResult?.data ?? projects;
|
|
return {
|
|
projectMap: new Map(
|
|
resolvedProjects.map((project) => [project.id, project]),
|
|
),
|
|
childProgressMap: progressResult?.data ?? childProgressMap,
|
|
};
|
|
},
|
|
[
|
|
childProgressMap,
|
|
projects,
|
|
refetchChildProgress,
|
|
refetchProjects,
|
|
],
|
|
);
|
|
|
|
const visibleStatuses = useMemo<IssueStatus[]>(() => {
|
|
// Default view shows every lifecycle status, `cancelled` last (its
|
|
// canonical position in ALL_STATUSES). An active status filter narrows to
|
|
// the selected subset while preserving that order.
|
|
if (statusFilters.length > 0) {
|
|
return ALL_STATUSES.filter((s) => statusFilters.includes(s));
|
|
}
|
|
return ALL_STATUSES;
|
|
}, [statusFilters]);
|
|
|
|
// Hidden columns are the lifecycle statuses not currently visible, so
|
|
// `cancelled` participates in the board show/hide controls exactly like the
|
|
// rest of the statuses.
|
|
const hiddenStatuses = useMemo<IssueStatus[]>(
|
|
() => ALL_STATUSES.filter((s) => !visibleStatuses.includes(s)),
|
|
[visibleStatuses],
|
|
);
|
|
|
|
const activeFilters = useMemo(
|
|
() => ({
|
|
priorityFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
agentRunningFilter,
|
|
runningIssueIds: workingIssueIDs,
|
|
creatorFilters,
|
|
projectFilters,
|
|
includeNoProject,
|
|
labelFilters,
|
|
propertyFilters,
|
|
showSubIssues,
|
|
}),
|
|
[
|
|
assigneeFilters,
|
|
agentRunningFilter,
|
|
creatorFilters,
|
|
includeNoAssignee,
|
|
includeNoProject,
|
|
labelFilters,
|
|
propertyFilters,
|
|
priorityFilters,
|
|
projectFilters,
|
|
showSubIssues,
|
|
workingIssueIDs,
|
|
],
|
|
);
|
|
|
|
const isLoading = serverGroupBranches.enabled
|
|
? serverGroupBranches.isLoading
|
|
: usesAssigneeBoard
|
|
? assigneeGroupsQuery.isLoading
|
|
: usesGantt
|
|
? ganttIssuesQuery.isLoading
|
|
: usesTable
|
|
? false
|
|
: serverStatusBranches.enabled
|
|
? serverStatusBranches.isLoading
|
|
: statusIssuesQuery.isLoading;
|
|
|
|
// Placeholder-backed revalidation of the ACTIVE query only. First loads are
|
|
// isLoading (no previous data to place-hold); gantt has no placeholder
|
|
// phase (its key carries no sort/filter).
|
|
const isRefreshing = serverGroupBranches.enabled
|
|
? serverGroupBranches.isRefreshing
|
|
: usesAssigneeBoard
|
|
? assigneeGroupsQuery.isPlaceholderData
|
|
: usesGantt
|
|
? false
|
|
: usesTable
|
|
? false
|
|
: serverStatusBranches.enabled
|
|
? serverStatusBranches.isRefreshing
|
|
: statusIssuesQuery.isPlaceholderData;
|
|
|
|
return {
|
|
surfaceIssues,
|
|
projectIssues: surfaceIssues,
|
|
issues,
|
|
swimlaneIssues,
|
|
ganttWorkingScopeIssues,
|
|
filteredGanttIssues,
|
|
assigneeGroups: usesAssigneeBoard ? filteredAssigneeGroups : undefined,
|
|
assigneeGroupQueryKey: usesAssigneeBoard
|
|
? activeAssigneeGroupsOptions.queryKey
|
|
: undefined,
|
|
assigneeGroupFilter: usesAssigneeBoard ? assigneeGroupFilter : undefined,
|
|
filter: queryPlan.queryFilter,
|
|
loadMoreScope: queryPlan.loadMoreScope,
|
|
loadMoreFilter: queryPlan.loadMoreFilter,
|
|
ganttIssues,
|
|
visibleStatuses,
|
|
hiddenStatuses,
|
|
statusPagination: serverStatusBranches.pagination,
|
|
activeFilters,
|
|
childProgressMap,
|
|
projectMap,
|
|
resolveTableExportLookups,
|
|
isLoading,
|
|
isRefreshing,
|
|
// isEmpty asserts "this window has no issues". The board/list/swimlane
|
|
// data IS the full window, so an empty result proves it. The gantt query
|
|
// is a scheduled-only PROJECTION — an empty subset cannot prove the
|
|
// window is empty, so never claim it (same "uncertain → don't assert"
|
|
// rule as surface membership). GanttView renders its own accurate
|
|
// "no scheduled issues" empty state instead of the generic create-issue
|
|
// one. Table owns its own branch-level loading, empty and retry states,
|
|
// so this shared legacy surface projection never asserts Table empty.
|
|
isEmpty:
|
|
!isLoading &&
|
|
!usesGantt &&
|
|
!usesTable &&
|
|
(serverStatusBranches.enabled
|
|
? serverStatusBranches.isTotalKnown &&
|
|
serverStatusBranches.total === 0
|
|
: serverGroupBranches.enabled
|
|
? !serverGroupBranches.isError &&
|
|
serverGroupBranches.total === 0
|
|
: surfaceIssues.length === 0),
|
|
};
|
|
}
|