import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; import { AgentIdentity } from "@/components/AgentIdentity"; import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useVisibilityRefetchInterval } from "@/lib/polling"; import { accessApi } from "../api/access"; import { useDialogActions } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { Link, useNavigate } from "@/lib/router"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; import { createIssueDetailPath, rememberIssueDetailLocationState, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; import { prefetchIssueDetailForNavigation } from "../lib/issueDetailCache"; import { buildSubIssueProgressSummary, shouldRenderSubIssueProgressSummary, type SubIssueProgressSummary, } from "../lib/issue-detail-subissues"; import { groupBy } from "../lib/groupBy"; import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, normalizeIssueFilterState, resolveIssueFilterWorkspaceId, shouldIncludeIssueFilterWorkspaceOption, issueStatusOrder, type IssueFilterState, } from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, normalizeInboxIssueColumns, resolveIssueWorkspaceName, type InboxIssueColumn, } from "../lib/inbox"; import { cn, formatDurationMs, formatTokens } from "../lib/utils"; import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; import { collectSubtreeLiveCounts } from "../lib/liveIssueIds"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, issueActivityTimestamp, issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; import { IssueGroupHeader } from "./IssueGroupHeader"; import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { IssueRow, type IssueRowPresentation } from "./IssueRow"; import { CollectionToolbar, type CollectionToolbarProps } from "./CollectionToolbar"; import { IssuesList as LegacyIssuesList } from "./LegacyIssuesList"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, User, Search, CircleSlash2, ChevronsDownUp, PanelTopClose, RotateCcw, ListCollapse, SquareKanban, } from "lucide-react"; import { KanbanBoard, KANBAN_BOARD_HIGH_VOLUME_THRESHOLD, KANBAN_COLD_STATUSES, KANBAN_COLUMN_DEFAULT_PAGE_SIZE, KANBAN_COLUMN_PAGE_SIZE_OPTIONS, type KanbanColumnPageSize, } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { getInboxKeyboardSelectionIndex } from "../lib/inbox"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; import { workflowSort } from "../lib/workflow-sort"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; import { loadTaskCollectionPreferences, saveTaskCollectionPreferences, type TaskCollectionPreferenceLocation, } from "../lib/task-collection-preferences"; import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup } from "../lib/task-date-groups"; import { deriveOriginatingActor, ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared"; import { Badge } from "@/components/ui/badge"; const ISSUE_SEARCH_DEBOUNCE_MS = 250; const ISSUE_SEARCH_RESULT_LIMIT = 200; const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; type IssuesListNavEntry = | { type: "group"; key: string; collapsed: boolean } | { type: "issue"; issue: Issue; hasChildren: boolean; expanded: boolean; budgetOrdinal: number }; function issuesListNavEntryKey(entry: IssuesListNavEntry): string { return entry.type === "group" ? `group:${entry.key}` : `issue:${entry.issue.id}`; } // CSS.escape is missing in some non-browser environments (jsdom tests). function escapeAttrValue(value: string): string { return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&"); } const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320; function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | null { if (!element || typeof window === "undefined") return null; let current = element.parentElement; while (current && current !== document.body && current !== document.documentElement) { const overflowY = window.getComputedStyle(current).overflowY; if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") { return current; } current = current.parentElement; } return null; } const boardIssueStatuses = ISSUE_STATUSES; const issueStatusLabels: Record = { backlog: "Backlog", todo: "Todo", in_progress: "In progress", in_review: "In review", done: "Done", blocked: "Blocked", cancelled: "Cancelled", }; const progressSegmentClasses: Record = { backlog: "bg-muted-foreground/40", todo: "bg-blue-500", in_progress: "bg-yellow-500", in_review: "bg-violet-500", done: "bg-green-500", blocked: "bg-red-500", cancelled: "bg-neutral-400", }; /* ── View state ── */ export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow"; export type BoardCardDensity = "auto" | "compact" | "comfortable"; export type BoardColdLaneMode = "auto" | "collapsed" | "expanded"; export type BoardColumnPageSize = KanbanColumnPageSize; export type IssueViewState = IssueFilterState & { sortField: IssueSortField; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; nestingEnabled: boolean; showDateGroupSeparators: boolean; collapsedGroups: string[]; collapsedParents: string[]; boardCardDensity: BoardCardDensity; boardColdLaneMode: BoardColdLaneMode; boardColumnPageSize: BoardColumnPageSize; }; const defaultViewState: IssueViewState = { ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", viewMode: "list", nestingEnabled: true, showDateGroupSeparators: true, collapsedGroups: [], collapsedParents: [], boardCardDensity: "auto", boardColdLaneMode: "expanded", boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE, }; function normalizeBoardCardDensity(value: unknown): BoardCardDensity { return value === "compact" || value === "comfortable" || value === "auto" ? value : "auto"; } function normalizeBoardColdLaneMode(value: unknown): BoardColdLaneMode { return value === "collapsed" || value === "expanded" || value === "auto" ? value : "auto"; } function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize { return KANBAN_COLUMN_PAGE_SIZE_OPTIONS.includes(value as BoardColumnPageSize) ? value as BoardColumnPageSize : KANBAN_COLUMN_DEFAULT_PAGE_SIZE; } function normalizeIssueViewState(value: unknown): IssueViewState { const parsed = value && typeof value === "object" ? value as Partial : {}; return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed), // A saved workspace selection has no filter control. Drop it on load. // `initialWorkspaces` from the page address is applied after this. workspaces: [], sortField: ["status", "priority", "title", "created", "updated", "workflow"].includes(parsed.sortField ?? "") ? parsed.sortField as IssueSortField : defaultViewState.sortField, sortDir: parsed.sortDir === "asc" ? "asc" : "desc", groupBy: ["status", "priority", "assignee", "project", "workspace", "parent", "none"].includes(parsed.groupBy ?? "") ? parsed.groupBy as IssueViewState["groupBy"] : defaultViewState.groupBy, viewMode: parsed.viewMode === "board" ? "board" : "list", nestingEnabled: parsed.nestingEnabled !== false, showDateGroupSeparators: parsed.showDateGroupSeparators !== false, collapsedGroups: Array.isArray(parsed.collapsedGroups) ? parsed.collapsedGroups.filter((entry): entry is string => typeof entry === "string") : [], collapsedParents: Array.isArray(parsed.collapsedParents) ? parsed.collapsedParents.filter((entry): entry is string => typeof entry === "string") : [], boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity), boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode), boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize), }; } function getInitialViewState( stored: { viewState: IssueViewState; source: "current" | "legacy" | "default" }, initialAssignees?: string[], defaultSortField?: IssueSortField, ): IssueViewState { const base = stored.source === "default" && defaultSortField ? { ...stored.viewState, sortField: defaultSortField, sortDir: "asc" as const } : stored.viewState; if (!initialAssignees) return base; return { ...base, assignees: initialAssignees, statuses: [], }; } function getInitialWorkspaceViewState( stored: { viewState: IssueViewState; source: "current" | "legacy" | "default" }, initialAssignees?: string[], initialWorkspaces?: string[], defaultSortField?: IssueSortField, initialStatuses?: string[], ): IssueViewState { const initial = getInitialViewState(stored, initialAssignees, defaultSortField); const scoped = initialWorkspaces ? { ...initial, workspaces: initialWorkspaces, statuses: [] } : initial; // A status preset (Active / Backlog / Done, and All as the empty set) is the // view's definition, so it wins over whatever the last session persisted. // `undefined` means "no preset" and leaves the stored statuses alone. return initialStatuses ? { ...scoped, statuses: initialStatuses } : scoped; } function getIssueColumnsStorageKey(key: string): string { return `${key}:issue-columns`; } function loadIssueCollectionPreferences( location: TaskCollectionPreferenceLocation, ) { return loadTaskCollectionPreferences({ ...location, defaultViewState: defaultViewState, defaultColumns: DEFAULT_INBOX_ISSUE_COLUMNS, normalizeViewState: normalizeIssueViewState, normalizeColumns: (value) => normalizeInboxIssueColumns(Array.isArray(value) ? value : []), }); } function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { if (state.sortField === "workflow") { const ordered = workflowSort(issues); return state.sortDir === "desc" ? [...ordered].reverse() : ordered; } const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); case "updated": return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); default: return 0; } }); return sorted; } // Only recency sorts (newest first) get date separators — for any other // sort/direction the boundaries would be meaningless. function issueDateSeparatorField(state: IssueViewState): "createdAt" | "updatedAt" | null { if (state.sortDir !== "desc") return null; if (state.sortField === "created") return "createdAt"; if (state.sortField === "updated") return "updatedAt"; return null; } const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000; const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS; export function issueAgeBucket(date: Date | string, now: number = Date.now()): 0 | 1 | 2 { const age = now - new Date(date).getTime(); if (age < AGE_BUCKET_DAY_MS) return 0; if (age < AGE_BUCKET_WEEK_MS) return 1; return 2; } export function issueAgeSeparatorLabel(bucket: 1 | 2): string { return bucket === 1 ? "Older than a day" : "Older than a week"; } export function issueAgeBucketsCrossed( previousBucket: 0 | 1 | 2, currentBucket: 0 | 1 | 2, ): Array<1 | 2> { const crossedBuckets: Array<1 | 2> = []; if (previousBucket < 1 && currentBucket >= 1) crossedBuckets.push(1); if (previousBucket < 2 && currentBucket >= 2) crossedBuckets.push(2); return crossedBuckets; } function IssueDateSeparator({ label }: { label: string }) { return (
); } function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean { if (!normalizedSearch) return true; return [ issue.identifier, issue.title, issue.description, ].some((value) => value?.toLowerCase().includes(normalizedSearch)); } function isActionableWorkflowStatus(status: IssueStatus): boolean { return status !== "done" && status !== "cancelled" && status !== "blocked"; } function buildChecklistStepNumberMap(issues: Issue[], nestingEnabled: boolean): Map { const stepNumberByIssueId = new Map(); if (!nestingEnabled) { issues.forEach((issue, index) => { stepNumberByIssueId.set(issue.id, String(index + 1)); }); return stepNumberByIssueId; } const { roots, childMap } = buildIssueTree(issues); const visit = (siblings: Issue[], prefix: string | null) => { siblings.forEach((issue, index) => { const stepNumber = prefix ? `${prefix}.${index + 1}` : String(index + 1); stepNumberByIssueId.set(issue.id, stepNumber); visit(childMap.get(issue.id) ?? [], stepNumber); }); }; visit(roots, null); issues.forEach((issue, index) => { if (!stepNumberByIssueId.has(issue.id)) { stepNumberByIssueId.set(issue.id, String(index + 1)); } }); return stepNumberByIssueId; } function buildPreviousSiblingIssueIdMap(issues: Issue[], nestingEnabled: boolean): Map { const previousSiblingByIssueId = new Map(); if (!nestingEnabled) { const previousByParentId = new Map(); for (const issue of issues) { if (!issue.parentId) continue; const previousSibling = previousByParentId.get(issue.parentId); if (previousSibling) { previousSiblingByIssueId.set(issue.id, previousSibling.id); } previousByParentId.set(issue.parentId, issue); } return previousSiblingByIssueId; } const { roots, childMap } = buildIssueTree(issues); const visit = (siblings: Issue[]) => { siblings.forEach((issue, index) => { const previousSibling = index > 0 ? siblings[index - 1] : null; if (issue.parentId && previousSibling?.parentId === issue.parentId) { previousSiblingByIssueId.set(issue.id, previousSibling.id); } visit(childMap.get(issue.id) ?? []); }); }; visit(roots); return previousSiblingByIssueId; } function shouldSuppressSinglePreviousSiblingBlockerChip( issue: Issue, unresolvedVisibleBlockerIds: string[], previousSiblingIssueId: string | undefined, ): boolean { return Boolean( issue.parentId && previousSiblingIssueId && (issue.blockedBy ?? []).length === 1 && unresolvedVisibleBlockerIds.length === 1 && unresolvedVisibleBlockerIds[0] === previousSiblingIssueId, ); } /* ── Component ── */ interface Agent { id: string; name: string; } type CreatorOption = { id: string; label: string; kind: "agent" | "user"; searchText?: string; }; type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; interface IssuesListProps { issues: Issue[]; isLoading?: boolean; error?: Error | null; agents?: Agent[]; projects?: ProjectOption[]; liveIssueIds?: Set; projectId?: string; viewStateKey: string; issueLinkState?: unknown; initialAssignees?: string[]; initialWorkspaces?: string[]; /** * Status preset applied on entry and whenever it changes, overriding the * persisted status filter. `[]` clears it; `undefined` leaves it alone. * Used by the Tasks view presets (PAP-670). */ initialStatuses?: string[]; initialSearch?: string; searchFilters?: Omit; searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; defaultSortField?: IssueSortField; showProgressSummary?: boolean; /** * When set together with `showProgressSummary`, the progress strip fetches * the recursive cost-summary for this parent issue and renders aggregate * tokens + wall-clock runtime for every run in the tree. */ parentIssueIdForCostSummary?: string; enableAgentCreatedVisibilityFilter?: boolean; hasMoreIssues?: boolean; isLoadingMoreIssues?: boolean; mutedIssueIds?: Set; issueBadgeById?: Map; onLoadMoreIssues?: () => void; onSearchChange?: (search: string) => void; /** Opt in per surface while the canonical task row rolls out across collections. */ rowPresentation?: IssueRowPresentation; /** Opt in per surface while the shared collection toolbar rolls out. */ toolbarPresentation?: "legacy" | "collection"; /** * Rendered before the create button in the toolbar's context slot — the hook * the merged Tasks surface uses to put its Views control there (PAP-670). */ toolbarContext?: ReactNode; onUpdateIssue: (id: string, data: Record) => void; } function LegacyIssuesToolbar({ context, search, controls }: CollectionToolbarProps) { return (
{context} {search}
{controls}
); } function IssueSearchInput({ value, onDebouncedChange, }: { value: string; onDebouncedChange?: (search: string) => void; }) { const [draftValue, setDraftValue] = useState(value); const lastCommittedValueRef = useRef(value); useEffect(() => { setDraftValue(value); lastCommittedValueRef.current = value; }, [value]); useEffect(() => { if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return; const timeoutId = window.setTimeout(() => { lastCommittedValueRef.current = draftValue; startTransition(() => { onDebouncedChange(draftValue); }); }, ISSUE_SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); }, [draftValue, onDebouncedChange]); return (
{ setDraftValue(e.target.value); }} onKeyDown={(e) => { if (shouldBlurPageSearchOnEnter({ key: e.key, isComposing: e.nativeEvent.isComposing, })) { e.currentTarget.blur(); return; } if (shouldBlurPageSearchOnEscape({ key: e.key, isComposing: e.nativeEvent.isComposing, currentValue: e.currentTarget.value, })) { e.currentTarget.blur(); } }} placeholder="Search tasks..." className="pl-7 text-xs sm:text-sm" aria-label="Search tasks" data-page-search-target="true" />
); } function SubIssueProgressSummaryStrip({ summary, issueLinkState, parentIssueIdForCostSummary, }: { summary: SubIssueProgressSummary; issueLinkState?: unknown; parentIssueIdForCostSummary?: string; }) { const target = summary.target; const targetIssue = target?.issue ?? null; const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? ""; const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined; const statusEntries = ISSUE_STATUSES .map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 })) .filter((entry) => entry.count > 0); // Refresh fast enough that the runtime ticks up while a sub-issue is still // running, but slow enough not to hammer the recursive CTE on idle trees. const hasInProgress = summary.inProgressCount > 0; const costRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5_000 }); const { data: costSummary } = useQuery({ queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }), queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }), enabled: !!parentIssueIdForCostSummary, refetchInterval: hasInProgress ? costRefetchInterval : false, }); const totalTokens = costSummary ? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens : 0; const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0); return (
{summary.doneCount}/{summary.totalCount} done {summary.inProgressCount} in progress {summary.blockedCount} blocked {showCostSummary && ( <> {formatTokens(totalTokens)} tokens {formatDurationMs(costSummary.runtimeMs)} runtime )}
{statusEntries.map(({ status, count }) => (
{target && targetIssue ? ( <>
{target.kind === "next" ? "Next up" : "Waiting on blockers"}
{targetIssue.identifier ?? targetIssue.id.slice(0, 8)} {" "} {targetIssue.title} ) : summary.totalCount === 0 ? (
No active sub-tasks
) : summary.doneCount === summary.totalCount ? (
All sub-tasks done
) : (
No actionable sub-tasks
)}
); } // Mobile-only indent for nested task rows (desktop uses IssueRow treeGuides). const MOBILE_TREE_INDENT = ["", "pl-4 sm:pl-0", "pl-8 sm:pl-0", "pl-12 sm:pl-0", "pl-16 sm:pl-0"]; export function IssuesList(props: IssuesListProps) { const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); return streamlinedUiEnabled ? : ; } function StreamlinedIssuesList({ issues, isLoading, error, agents, projects, liveIssueIds, projectId, viewStateKey, issueLinkState, initialAssignees, initialWorkspaces, initialStatuses, initialSearch, searchFilters, searchWithinLoadedIssues = false, baseCreateIssueDefaults, createIssueLabel, defaultSortField, showProgressSummary = false, parentIssueIdForCostSummary, enableAgentCreatedVisibilityFilter = false, hasMoreIssues = false, isLoadingMoreIssues = false, mutedIssueIds, issueBadgeById, onLoadMoreIssues, onSearchChange, rowPresentation = "legacy", toolbarContext, toolbarPresentation = "legacy", onUpdateIssue, }: IssuesListProps) { const rootRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); // Keyboard selection for the list view (mirrors the inbox). Hover moves the // selection only after real pointer movement, so keyboard-driven scrolling // doesn't hand the selection to whatever row lands under the cursor. const [selectedNavKey, setSelectedNavKey] = useState(null); const pointerMovedSinceKeyNavRef = useRef(true); useEffect(() => { const handlePointerMove = () => { pointerMovedSinceKeyNavRef.current = true; }; window.addEventListener("mousemove", handlePointerMove, { passive: true }); return () => window.removeEventListener("mousemove", handlePointerMove); }, []); // Which entry the cursor is over, tracked WITHOUT React state so scrubbing the // list costs zero re-renders (hover paints via CSS `:hover`). Keyboard nav // reads this to continue from the hovered row. Key-based, so it self-heals if // the entry disappears (findIndex → -1). const hoveredNavKeyRef = useRef(null); const setNavSelectionFromPointer = useCallback((navKey: string) => { if (!pointerMovedSinceKeyNavRef.current) return; hoveredNavKeyRef.current = navKey; // Drop any keyboard selection band the moment the mouse takes over, so we // never show two identical highlights at once. React bails when already // null, so continuous hovering triggers no re-render. setSelectedNavKey((prev) => (prev === null ? prev : null)); }, []); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialogActions(); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const experimentalSettingsLoaded = experimentalSettings !== undefined; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; const preferenceLocation: TaskCollectionPreferenceLocation = { companyId: selectedCompanyId ?? "__unscoped__", collectionKey: viewStateKey, legacyViewStorageKey: scopedKey, legacyColumnsStorageKey: getIssueColumnsStorageKey(scopedKey), }; const initialAssigneesKey = initialAssignees?.join("|") ?? ""; const initialWorkspacesKey = initialWorkspaces?.join("|") ?? ""; const initialStatusesKey = initialStatuses ? `set:${initialStatuses.join("|")}` : ""; const initialPreferencesRef = useRef | null>(null); if (initialPreferencesRef.current === null) { initialPreferencesRef.current = loadIssueCollectionPreferences(preferenceLocation); } const initialPreferences = initialPreferencesRef.current; const [viewState, setViewState] = useState(() => getInitialWorkspaceViewState( initialPreferences, initialAssignees, initialWorkspaces, defaultSortField, initialStatuses, ), ); const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey); useEffect(() => { if (!enableAgentCreatedVisibilityFilter) return; setViewState((previous) => previous.showAgentCreatedTasks === showAgentCreatedTasks ? previous : { ...previous, showAgentCreatedTasks }); }, [enableAgentCreatedVisibilityFilter, showAgentCreatedTasks]); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [hierarchyRowBudget, setHierarchyRowBudget] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(initialPreferences.columns); const renderedIssueIdsRef = useRef(""); const initialServerFillRequestedRef = useRef(false); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); }, [initialSearch]); // Reload view state whenever the persisted context changes. const prevViewStateContextKey = useRef( `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}::${initialStatusesKey}`, ); useEffect(() => { const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}::${initialStatusesKey}`; if (prevViewStateContextKey.current !== nextContextKey) { prevViewStateContextKey.current = nextContextKey; const preferences = loadIssueCollectionPreferences(preferenceLocation); setViewState(getInitialWorkspaceViewState( preferences, initialAssignees, initialWorkspaces, defaultSortField, initialStatuses, )); setVisibleIssueColumns(preferences.columns); } }, [ scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, initialStatuses, initialStatusesKey, defaultSortField, preferenceLocation.companyId, preferenceLocation.collectionKey, preferenceLocation.legacyViewStorageKey, preferenceLocation.legacyColumnsStorageKey, ]); const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; saveTaskCollectionPreferences(preferenceLocation, { viewState: next, columns: visibleIssueColumns, }); return next; }); }, [ preferenceLocation.companyId, preferenceLocation.collectionKey, preferenceLocation.legacyColumnsStorageKey, preferenceLocation.legacyViewStorageKey, visibleIssueColumns, ]); useEffect(() => { if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return; updateView({ externalObjectStatuses: [] }); }, [experimentalSettingsLoaded, externalObjectsEnabled, updateView, viewState.externalObjectStatuses.length]); // Prune stale IDs from collapsedParents whenever the issue list changes. // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. useEffect(() => { // Contextual children are loaded separately from independent results. if (enableAgentCreatedVisibilityFilter) return; const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]); const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id)); if (pruned.length !== viewState.collapsedParents.length) { updateView({ collapsedParents: pruned }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [issues]); const { data: searchedIssues = [] } = useQuery({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : "compact", ISSUE_SEARCH_RESULT_LIMIT, enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { projection: enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : undefined, q: normalizedIssueSearch, projectId, limit: ISSUE_SEARCH_RESULT_LIMIT, ...searchFilters, ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues, placeholderData: (previousData) => previousData, }); const boardIssueQueries = useQueries({ queries: boardIssueStatuses.map((status) => ({ queryKey: [ ...queryKeys.issues.list(selectedCompanyId ?? "__no-company__"), "board-column", status, normalizedIssueSearch, projectId ?? "__all-projects__", searchFilters ?? {}, "compact", ISSUE_BOARD_COLUMN_RESULT_LIMIT, enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.listCompact(selectedCompanyId!, { ...searchFilters, ...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}), projectId, status, limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, ...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}), }, { signal }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues, placeholderData: (previousData: Issue[] | undefined) => previousData, })), }); const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const companyUserLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); const companyUserProfileMap = useMemo( () => buildCompanyUserProfileMap(companyMembers?.users), [companyMembers?.users], ); const projectById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { map.set(project.id, { name: project.name, color: project.color ?? null }); } return map; }, [projects]); const projectWorkspaceById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { for (const workspace of project.workspaces ?? []) { map.set(workspace.id, { name: workspace.name || project.name, projectId: project.id }); } } return map; }, [projects]); const defaultProjectWorkspaceIdByProjectId = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { const defaultWorkspaceId = project.executionWorkspacePolicy?.defaultProjectWorkspaceId ?? project.primaryWorkspace?.id ?? null; if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId); } return map; }, [projects]); const defaultProjectWorkspaceIds = useMemo( () => new Set(defaultProjectWorkspaceIdByProjectId.values()), [defaultProjectWorkspaceIdByProjectId], ); const executionWorkspaceById = useMemo(() => { const map = new Map(); for (const workspace of executionWorkspaces) { const projectWorkspace = workspace.projectWorkspaceId ? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null : null; map.set(workspace.id, { name: workspace.name, mode: workspace.mode, projectWorkspaceId: workspace.projectWorkspaceId ?? null, projectId: projectWorkspace?.projectId ?? null, }); } return map; }, [executionWorkspaces, projectWorkspaceById]); const issueFilterWorkspaceContext = useMemo(() => ({ executionWorkspaceById, defaultProjectWorkspaceIdByProjectId, }), [defaultProjectWorkspaceIdByProjectId, executionWorkspaceById]); const workspaceNameMap = useMemo(() => { const map = new Map(); for (const [workspaceId, workspace] of projectWorkspaceById) { if (!shouldIncludeIssueFilterWorkspaceOption({ id: workspaceId }, defaultProjectWorkspaceIds)) continue; map.set(workspaceId, workspace.name); } for (const [workspaceId, workspace] of executionWorkspaceById) { if (!shouldIncludeIssueFilterWorkspaceOption({ id: workspaceId, mode: workspace.mode, projectWorkspaceId: workspace.projectWorkspaceId, }, defaultProjectWorkspaceIds)) continue; map.set(workspaceId, workspace.name); } return map; }, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]); const creatorOptions = useMemo(() => { const options = new Map(); const knownAgentIds = new Set(); if (currentUserId) { options.set(`user:${currentUserId}`, { id: `user:${currentUserId}`, label: currentUserId === "local-board" ? "Board" : "Me", kind: "user", searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, }); } for (const issue of issues) { if (issue.createdByUserId) { const id = `user:${issue.createdByUserId}`; if (!options.has(id)) { options.set(id, { id, label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), kind: "user", searchText: `${issue.createdByUserId} board user human`, }); } } } for (const agent of agents ?? []) { knownAgentIds.add(agent.id); const id = `agent:${agent.id}`; if (!options.has(id)) { options.set(id, { id, label: agent.name, kind: "agent", searchText: `${agent.name} ${agent.id} agent`, }); } } for (const issue of issues) { if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { const id = `agent:${issue.createdByAgentId}`; if (!options.has(id)) { options.set(id, { id, label: issue.createdByAgentId.slice(0, 8), kind: "agent", searchText: `${issue.createdByAgentId} agent`, }); } } } return [...options.values()].sort((a, b) => { if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; return a.label.localeCompare(b.label); }); }, [agents, currentUserId, issues]); const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), [isolatedWorkspacesEnabled], ); const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]); const subtreeLiveCounts = useMemo( () => collectSubtreeLiveCounts(issues, liveIssueIds ?? new Set()), [issues, liveIssueIds], ); const visibleTrailingIssueColumns = useMemo( () => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column) && (rowPresentation === "legacy" || column !== "updated"), ), [availableIssueColumnSet, rowPresentation, visibleIssueColumnSet], ); const issueById = useMemo(() => { const map = new Map(); for (const issue of issues) { map.set(issue.id, issue); } return map; }, [issues]); const issueTitleMap = useMemo(() => { const map = new Map(); for (const issue of issues) { map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title); } return map; }, [issues]); const boardIssues = useMemo(() => { if (viewState.viewMode !== "board" || searchWithinLoadedIssues) return null; const merged = new Map(); let isPending = false; for (const query of boardIssueQueries) { isPending ||= query.isPending; for (const issue of query.data ?? []) { merged.set(issue.id, issue); } } if (merged.size > 0) return [...merged.values()]; return isPending ? issues : []; }, [boardIssueQueries, issues, searchWithinLoadedIssues, viewState.viewMode]); const boardColumnLimitReached = useMemo( () => viewState.viewMode === "board" && !searchWithinLoadedIssues && boardIssueQueries.some((query) => (query.data?.length ?? 0) === ISSUE_BOARD_COLUMN_RESULT_LIMIT), [boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode], ); const sourceIssues = useMemo(() => { const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues; return boardIssues ?? (useRemoteSearch ? searchedIssues : issues); }, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]); const searchScopedIssues = useMemo( () => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues ? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch)) : sourceIssues, [normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues], ); const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0; const issueIdsForExternalObjectSummaries = useMemo( () => (viewState.viewMode === "list" || hasExternalObjectStatusFilters ? searchScopedIssues.map((issue) => issue.id) : []), [hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode], ); const { summaries: externalObjectSummaryByIssueId, isLoading: externalObjectSummariesLoading, isReady: externalObjectSummariesReady, error: externalObjectSummariesError, refetch: retryExternalObjectSummaries, } = useIssueExternalObjectSummaries( selectedCompanyId, issueIdsForExternalObjectSummaries, ); const issueFilterContext = useMemo(() => ({ ...issueFilterWorkspaceContext, externalObjectSummaryByIssueId, externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]); const externalObjectFilterLoading = hasExternalObjectStatusFilters && externalObjectSummariesLoading && !externalObjectSummariesReady; const externalObjectFilterError = hasExternalObjectStatusFilters ? externalObjectSummariesError : null; const filtered = useMemo(() => { const filteredByControls = applyIssueFilters( searchScopedIssues, viewState, currentUserId, enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ); return sortIssues(filteredByControls, viewState); }, [ searchScopedIssues, viewState, currentUserId, enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, ]); const progressSummary = useMemo( () => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length) ? buildSubIssueProgressSummary(issues) : null, [issues, showProgressSummary], ); const checklistAffordanceEnabled = useMemo( () => defaultSortField === "workflow" && viewState.groupBy === "none", [defaultSortField, viewState.groupBy], ); const workflowChecklistMeta = useMemo(() => { if (!checklistAffordanceEnabled) return null; const visibleIssueIds = new Set(filtered.map((issue) => issue.id)); const stepNumberByIssueId = buildChecklistStepNumberMap(filtered, viewState.nestingEnabled); const previousSiblingIssueIdByIssueId = buildPreviousSiblingIssueIdMap(filtered, viewState.nestingEnabled); const unresolvedVisibleBlockersByIssueId = new Map(); filtered.forEach((issue) => { const unresolvedVisible = (issue.blockedBy ?? []) .map((blocker) => blocker.id) .filter((blockerId) => { if (!visibleIssueIds.has(blockerId)) return false; const blockerIssue = issueById.get(blockerId); if (!blockerIssue) return false; return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled"; }); const shouldSuppressChip = shouldSuppressSinglePreviousSiblingBlockerChip( issue, unresolvedVisible, previousSiblingIssueIdByIssueId.get(issue.id), ); unresolvedVisibleBlockersByIssueId.set(issue.id, shouldSuppressChip ? [] : unresolvedVisible); }); const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null; const currentStepIssue = firstActionable ?? filtered.find((issue) => issue.status === "blocked") ?? null; return { stepNumberByIssueId, unresolvedVisibleBlockersByIssueId, currentStepIssueId: currentStepIssue?.id ?? null, }; }, [checklistAffordanceEnabled, filtered, issueById, viewState.nestingEnabled]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), enabled: !!selectedCompanyId, }); const activeFilterCount = countActiveIssueFilters(viewState, enableAgentCreatedVisibilityFilter); const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; const boardCompactCards = viewState.boardCardDensity === "compact" || (viewState.boardCardDensity === "auto" && boardHighVolume); const boardCollapsedStatuses = useMemo( () => viewState.boardColdLaneMode === "collapsed" || (viewState.boardColdLaneMode === "auto" && boardHighVolume) ? [...KANBAN_COLD_STATUSES] : [], [boardHighVolume, viewState.boardColdLaneMode], ); const boardDensityCustomized = viewState.boardCardDensity !== "auto" || viewState.boardColdLaneMode !== "auto" || viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE; const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list"; const hierarchy = useIssueHierarchyContext( selectedCompanyId, filtered.slice(0, renderedIssueRowLimit), viewState.collapsedParents, hierarchyEnabled, hierarchyRowBudget, ); // Resolve the initial tree before reveal: inserting ancestors/children later would move rows. // Hierarchy queries retain existing context during subsequent reads. const collectionLoading = Boolean(isLoading || externalObjectFilterLoading || (hierarchyEnabled && hierarchy.isLoading)); const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); return issueStatusOrder .filter((s) => groups[s]?.length) .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); return issuePriorityOrder .filter((p) => groups[p]?.length) .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy( filtered, (issue) => resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext) ?? "__no_workspace", ); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last if (a === "__no_workspace") return 1; if (b === "__no_workspace") return -1; return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); }) .map((key) => ({ key, label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)), items: groups[key]!, })); } if (viewState.groupBy === "project") { const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project"); return Object.keys(groups) .sort((a, b) => { if (a === "__no_project") return 1; if (b === "__no_project") return -1; const labelA = projectById.get(a)?.name ?? a; const labelB = projectById.get(b)?.name ?? b; return labelA.localeCompare(labelB); }) .map((key) => ({ key, label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)), items: groups[key]!, })); } if (viewState.groupBy === "parent") { const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no parent" last if (a === "__no_parent") return 1; if (b === "__no_parent") return -1; return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); }) .map((key) => ({ key, label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)), items: groups[key]!, })); } // assignee const groups = groupBy( filtered, (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), ); return Object.keys(groups).map((key) => ({ key, label: key === "__unassigned" ? "Unassigned" : key.startsWith("__user:") ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User") : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); }, [ filtered, issueFilterWorkspaceContext, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap, projectById, ]); const groupedContent = useMemo(() => baseGroupedContent.map((group) => ({ ...group, items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list" ? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues]) : group.items, })), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.nestingEnabled, viewState.viewMode]); const visibleListRowCount = groupedContent.reduce((total, group) => total + group.items.length, 0); // Flattened visible order (group headers, then tree DFS per group — // collapsed groups keep their header entry but skip their rows) — must // match render order below for keyboard traversal. `budgetOrdinal` counts // rows the way the progressive renderer consumes its budget (collapsed // groups still consume rows; collapsed parents' subtrees do not). const flatNavEntries = useMemo(() => { if (viewState.viewMode !== "list") return [] as IssuesListNavEntry[]; const out: IssuesListNavEntry[] = []; let budgetCount = 0; for (const group of groupedContent) { const collapsed = Boolean(group.label) && viewState.collapsedGroups.includes(group.key); if (group.label) out.push({ type: "group", key: group.key, collapsed }); const { roots, childMap } = viewState.nestingEnabled ? buildIssueTree(group.items) : { roots: group.items, childMap: new Map() }; const walk = (issue: Issue) => { budgetCount += 1; const children = childMap.get(issue.id) ?? []; const expanded = !viewState.collapsedParents.includes(issue.id); if (!collapsed) { out.push({ type: "issue", issue, hasChildren: children.length > 0, expanded, budgetOrdinal: budgetCount, }); } if (expanded) for (const child of children) walk(child); }; for (const root of roots) walk(root); } return out; }, [ groupedContent, viewState.viewMode, viewState.collapsedGroups, viewState.collapsedParents, viewState.nestingEnabled, ]); const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( selectedCompanyId, rootRef, flatNavEntries.filter((entry) => entry.type === "issue").map((entry) => entry.type === "issue" ? entry.issue.id : "").join(",") + ":" + renderedIssueRowLimit, enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" && !collectionLoading, [...issues, ...hierarchy.issues], ); const listNavStateRef = useRef({ flatNavEntries, selectedNavKey, viewMode: viewState.viewMode, issueLinkState, collapsedGroups: viewState.collapsedGroups, collapsedParents: viewState.collapsedParents, updateView, }); listNavStateRef.current = { flatNavEntries, selectedNavKey, viewMode: viewState.viewMode, issueLinkState, collapsedGroups: viewState.collapsedGroups, collapsedParents: viewState.collapsedParents, updateView, }; const findSelectedNavElement = useCallback((navKey: string) => { if (navKey.startsWith("group:")) { const header = rootRef.current?.querySelector( `[data-issues-group-key="${escapeAttrValue(navKey.slice("group:".length))}"]`, ); return header instanceof HTMLElement ? header : null; } const row = rootRef.current?.querySelector( `[data-issue-row-id="${escapeAttrValue(navKey.slice("issue:".length))}"]`, ); const link = row?.querySelector("[data-inbox-issue-link]"); return link instanceof HTMLElement ? link : null; }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const target = e.target; if ( !(target instanceof HTMLElement) || isKeyboardShortcutTextInputTarget(target) || hasBlockingShortcutDialog(document) || e.metaKey || e.ctrlKey || e.altKey ) { return; } const st = listNavStateRef.current; if (st.viewMode !== "list" || st.flatNavEntries.length === 0) return; // The row a keystroke acts on: the hovered row when the mouse moved since // the last key nav (so "hover a row → press Arrow/Enter" acts on it), // otherwise the keyboard selection. Hover no longer writes selection // state, so this threads the pointer position into every handler. const indexOfKey = (key: string | null) => key ? st.flatNavEntries.findIndex((entry) => issuesListNavEntryKey(entry) === key) : -1; const hoveredIndex = indexOfKey(hoveredNavKeyRef.current); const fromHover = pointerMovedSinceKeyNavRef.current && hoveredIndex >= 0; const currentIndex = fromHover ? hoveredIndex : indexOfKey(st.selectedNavKey); switch (e.key) { case "j": case "ArrowDown": case "k": case "ArrowUp": { e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; const direction = e.key === "j" || e.key === "ArrowDown" ? "next" : "previous"; const nextIndex = getInboxKeyboardSelectionIndex(currentIndex, st.flatNavEntries.length, direction); const nextEntry = st.flatNavEntries[nextIndex]; if (!nextEntry) break; setSelectedNavKey(issuesListNavEntryKey(nextEntry)); // The list renders progressively; make sure the selected row is // within the render budget so the band mounts and can scroll into // view (the +1 keeps the next row visible as a scroll cue). if (nextEntry.type === "issue") { setRenderedIssueRowLimit((current) => Math.max(current, nextEntry.budgetOrdinal + 1)); } break; } case "ArrowLeft": case "ArrowRight": { // Groups and parent tasks collapse/expand with the same keys as the // inbox. const entry = st.flatNavEntries[currentIndex]; if (!entry) return; const collapse = e.key === "ArrowLeft"; if (entry.type === "group") { e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedNavKey(issuesListNavEntryKey(entry)); st.updateView({ collapsedGroups: collapse ? (st.collapsedGroups.includes(entry.key) ? st.collapsedGroups : [...st.collapsedGroups, entry.key]) : st.collapsedGroups.filter((k) => k !== entry.key), }); break; } if (!entry.hasChildren) return; e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedNavKey(issuesListNavEntryKey(entry)); st.updateView({ collapsedParents: collapse ? (st.collapsedParents.includes(entry.issue.id) ? st.collapsedParents : [...st.collapsedParents, entry.issue.id]) : st.collapsedParents.filter((id) => id !== entry.issue.id), }); break; } case "Enter": { const entry = st.flatNavEntries[currentIndex]; if (!entry || entry.type !== "issue") return; e.preventDefault(); // Navigate from the entry data (like the inbox) rather than the DOM // row — the selected row may sit past the mounted render batch. const issue = entry.issue; const pathId = issue.identifier ?? issue.id; const detailState = withIssueDetailHeaderSeed(st.issueLinkState, issue); rememberIssueDetailLocationState(pathId, detailState); // Seed the full list-row snapshot + first comments page before we // navigate so keyboard-driven opens paint from cache instantly, the // same way pointer hover/click does through the issue link. Mirrors // the inbox Enter handler. void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); navigate(createIssueDetailPath(pathId), { state: detailState }); break; } default: return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [navigate, queryClient]); // Keep the keyboard selection visible while navigating. Depends on the // render budget too: a selection past the mounted batch scrolls once its // row mounts. useEffect(() => { if (!selectedNavKey) return; findSelectedNavElement(selectedNavKey)?.scrollIntoView({ block: "nearest" }); }, [findSelectedNavElement, renderedIssueRowLimit, selectedNavKey]); useEffect(() => { if (viewState.viewMode !== "list") return; const nextIssueIds = filtered.map((issue) => issue.id).join("|"); const previousIssueIds = renderedIssueIdsRef.current; renderedIssueIdsRef.current = nextIssueIds; const sameResultsOrAppend = previousIssueIds.length > 0 && nextIssueIds.startsWith(previousIssueIds); if (!sameResultsOrAppend) setHierarchyRowBudget(INITIAL_ISSUE_ROW_RENDER_LIMIT); setRenderedIssueRowLimit((current) => { const nextInitialLimit = Math.min(visibleListRowCount, INITIAL_ISSUE_ROW_RENDER_LIMIT); // Context arrives asynchronously and must not undo a reveal request. if (hierarchyEnabled && sameResultsOrAppend) return Math.max(current, nextInitialLimit); const listAppended = sameResultsOrAppend && visibleListRowCount >= current; if (listAppended) return Math.min(visibleListRowCount, Math.max(current, nextInitialLimit)); return nextInitialLimit; }); }, [filtered, visibleListRowCount, viewState.viewMode, hierarchyEnabled]); const hasMoreHierarchyRows = hierarchyEnabled && hierarchy.hasMore; const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < visibleListRowCount; const remainingIssueRowCount = Math.max(visibleListRowCount - renderedIssueRowLimit, 0); const loadMoreIssueRows = useCallback(() => { if (viewState.viewMode !== "list") return; if (hasMoreHierarchyRows && !hierarchy.isFetching) { setHierarchyRowBudget((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); setRenderedIssueRowLimit((current) => current + ISSUE_ROW_RENDER_BATCH_SIZE); return; } if (hasMoreRenderedRows) { setRenderedIssueRowLimit((current) => Math.min(visibleListRowCount, current + ISSUE_ROW_RENDER_BATCH_SIZE)); return; } if (hasMoreIssues && !isLoadingMoreIssues) { onLoadMoreIssues?.(); } }, [ visibleListRowCount, hasMoreIssues, hasMoreRenderedRows, hasMoreHierarchyRows, hierarchy.isFetching, isLoadingMoreIssues, onLoadMoreIssues, viewState.viewMode, ]); const canLoadMoreIssues = viewState.viewMode === "list" && !isLoading && (hasMoreRenderedRows || (hasMoreHierarchyRows && !hierarchy.isFetching) || (hasMoreIssues && !isLoadingMoreIssues)); useEffect(() => { if (!canLoadMoreIssues) return; let animationFrameId: number | null = null; const scrollContainer = findIssuesScrollContainer(rootRef.current); const scrollTarget: Window | HTMLElement = scrollContainer ?? window; const checkScrollPosition = (trigger: "initial" | "scroll" | "resize" = "scroll") => { if (animationFrameId !== null) return; animationFrameId = window.requestAnimationFrame(() => { animationFrameId = null; const scrollHeight = scrollContainer?.scrollHeight ?? document.documentElement.scrollHeight; if (scrollHeight === 0) return; const viewportHeight = scrollContainer?.clientHeight ?? window.innerHeight; const scrollBottom = scrollContainer ? scrollContainer.scrollTop + scrollContainer.clientHeight : window.scrollY + window.innerHeight; const hasScrollableOverflow = scrollHeight > viewportHeight + 1; const threshold = scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX; if (scrollBottom >= threshold) { if (trigger === "initial" && !hasMoreRenderedRows && hasMoreIssues && !hasScrollableOverflow) { if (initialServerFillRequestedRef.current) return; initialServerFillRequestedRef.current = true; } loadMoreIssueRows(); } }); }; const handleScroll = () => checkScrollPosition("scroll"); const handleResize = () => checkScrollPosition("resize"); scrollTarget.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("resize", handleResize); checkScrollPosition("initial"); return () => { scrollTarget.removeEventListener("scroll", handleScroll); window.removeEventListener("resize", handleResize); if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId); }; }, [canLoadMoreIssues, hasMoreIssues, hasMoreRenderedRows, loadMoreIssueRows]); const newIssueDefaults = useCallback((group?: { key: string; items: Issue[] }) => { const groupKey = group?.key; const defaults: Record = { ...(baseCreateIssueDefaults ?? {}) }; if (projectId && defaults.projectId === undefined) defaults.projectId = projectId; if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") { if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); else defaults.assigneeAgentId = groupKey; } else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey; else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") { const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey || issue.projectWorkspaceId === groupKey, ) ?? null; const executionWorkspace = executionWorkspaceById.get(groupKey); if (executionWorkspace) { defaults.executionWorkspaceId = groupKey; defaults.executionWorkspaceMode = "reuse_existing"; if (executionWorkspace.projectWorkspaceId) defaults.projectWorkspaceId = executionWorkspace.projectWorkspaceId; const groupedProjectId = executionWorkspace.projectId ?? (executionWorkspace.projectWorkspaceId ? projectWorkspaceById.get(executionWorkspace.projectWorkspaceId)?.projectId : null) ?? (representativeIssue?.executionWorkspaceId === groupKey ? representativeIssue.projectId : null); if (groupedProjectId) defaults.projectId = groupedProjectId; } else { const projectWorkspace = projectWorkspaceById.get(groupKey); if (projectWorkspace) { defaults.projectWorkspaceId = groupKey; defaults.projectId = projectWorkspace.projectId; } } } else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") { const parentIssue = issueById.get(groupKey); if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId)); else defaults.parentId = groupKey; } } return defaults; }, [ baseCreateIssueDefaults, currentUserId, executionWorkspaceById, issueById, projectId, projectWorkspaceById, viewState.groupBy, ]); const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Task"; const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Task"; const openCreateIssueDialog = useCallback((group?: { key: string; items: Issue[] }) => { openNewIssue(newIssueDefaults(group)); }, [newIssueDefaults, openNewIssue]); const filterToWorkspace = useCallback((workspaceId: string) => { updateView({ workspaces: [workspaceId] }); }, [updateView]); const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); saveTaskCollectionPreferences(preferenceLocation, { viewState, columns: normalized, }); }, [ preferenceLocation.companyId, preferenceLocation.collectionKey, preferenceLocation.legacyColumnsStorageKey, preferenceLocation.legacyViewStorageKey, viewState, ]); const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => { if (enabled) { setIssueColumns([...visibleIssueColumns, column]); return; } setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); }, [setIssueColumns, visibleIssueColumns]); const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }, [onUpdateIssue]); let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY; const IssuesToolbar = toolbarPresentation === "collection" ? CollectionToolbar : LegacyIssuesToolbar; return (
void retryRowDetails()} /> {progressSummary ? ( ) : null} {/* Toolbar */} {toolbarContext}
) : ( )} search={( { setIssueSearch(nextSearch); onSearchChange?.(nextSearch); }} /> )} controls={( <> {/* View mode toggle */}
{viewState.viewMode === "list" && ( )} {viewState.viewMode === "board" && ( <>
{KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => ( ))}
)} updateView({ showDateGroupSeparators: enabled })} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which task columns stay visible" iconOnly rowPresentation={rowPresentation} /> ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} enableAgentCreatedVisibilityFilter={enableAgentCreatedVisibilityFilter} iconOnly presentation={rowPresentation === "task" ? "streamlined" : "legacy"} /> {/* Sort (list view only) */} {viewState.viewMode === "list" && (
{/* PAP-411: "priority" sort option hidden behind SHOW_TASK_PRIORITY_UI (comparator stays dormant). */} {([ ["workflow", "Workflow"], ["status", "Status"], ["priority", "Priority"], ["title", "Title"], ["created", "Created"], ["updated", "Updated"], ] as const) .filter(([field]) => SHOW_TASK_PRIORITY_UI || field !== "priority") .map(([field, label]) => ( ))}
)} {/* Group (list view only) */} {viewState.viewMode === "list" && (
{/* PAP-411: "priority" group-by option hidden behind SHOW_TASK_PRIORITY_UI (group logic stays dormant). */} {([ ["status", "Status"], ["priority", "Priority"], ["assignee", "Responsible"], ["project", "Project"], ["workspace", "Workspace"], ["parent", "Parent Task"], ["none", "None"], ] as const) .filter(([value]) => SHOW_TASK_PRIORITY_UI || value !== "priority") .map(([value, label]) => ( ))}
)} )} /> {collectionLoading && !externalObjectFilterError && } {error &&

{error.message}

} {externalObjectFilterError && (

Unable to filter tasks by external status: {externalObjectFilterError.message}

)} {!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (

Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.

)} {boardColumnLimitReached && (

Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.

)} {hierarchy.error ?

Failed to load task hierarchy: {hierarchy.error.message}

: null} {!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && ( openCreateIssueDialog()} /> )} {!collectionLoading && !externalObjectFilterError && (viewState.viewMode === "board" ? ( ) : (
{groupedContent.map((group) => { if (remainingRowsToRender <= 0) return null; return ( { updateView({ collapsedGroups: open ? viewState.collapsedGroups.filter((k) => k !== group.key) : [...viewState.collapsedGroups, group.key], }); }} > {group.label && ( // Left inset aligns the header chevron with the nested task // chevrons: tasks-list rows sit at pl-1 before their chevron // (no unread column), so the band adds no extra left inset.
setSelectedNavKey(`group:${group.key}`)} onMouseEnter={() => setNavSelectionFromPointer(`group:${group.key}`)} > { updateView({ collapsedGroups: viewState.collapsedGroups.includes(group.key) ? viewState.collapsedGroups.filter((k) => k !== group.key) : [...viewState.collapsedGroups, group.key], }); }} trailing={( )} />
)} {(() => { const { roots, childMap } = viewState.nestingEnabled ? buildIssueTree(group.items) : { roots: group.items, childMap: new Map() }; const renderIssueRow = (issue: Issue, depth: number) => { issue = enrichIssueRow(issue); if (remainingRowsToRender <= 0) return null; remainingRowsToRender -= 1; const children = childMap.get(issue.id) ?? []; const hasChildren = children.length > 0; const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0; const isExpanded = !viewState.collapsedParents.includes(issue.id); const useDeferredRowRendering = !(hasChildren && isExpanded); const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null; const issueBadge = issueBadgeById?.get(issue.id); const isMutedIssue = mutedIssueIds?.has(issue.id) === true; const assigneeUserProfile = issue.assigneeUserId ? companyUserProfileMap.get(issue.assigneeUserId) ?? null : null; const assigneeUserLabel = formatAssigneeUserLabel( issue.assigneeUserId, currentUserId, companyUserLabelMap, ) ?? assigneeUserProfile?.label ?? null; const originatingActor = deriveOriginatingActor(issue); const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null; const originatingViaAgentId = originatingActor?.kind === "user" ? originatingActor.viaAgentId ?? null : null; const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { e.preventDefault(); e.stopPropagation(); updateView({ collapsedParents: isExpanded ? [...viewState.collapsedParents, issue.id] : viewState.collapsedParents.filter((id) => id !== issue.id), }); }; const checklistMeta = workflowChecklistMeta; const checklistStepNumber = checklistMeta?.stepNumberByIssueId.get(issue.id) ?? null; const unresolvedVisibleBlockers = checklistMeta?.unresolvedVisibleBlockersByIssueId.get(issue.id) ?? []; const checklistRowId = checklistMeta ? `issue-workflow-row-${issue.id}` : undefined; const doneRowTitleClass = checklistMeta && issue.status === "done" ? "text-muted-foreground" : undefined; const visibleBlockerChips = unresolvedVisibleBlockers .map((blockerId) => { const blockerIssue = issueById.get(blockerId); if (!blockerIssue) return null; const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8); const blockerStep = checklistMeta?.stepNumberByIssueId.get(blockerId); const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : ""; return { blockerId, chipLabel: `blocked by ${label}${blockerStepSuffix}` }; }) .filter((chip): chip is { blockerId: string; chipLabel: string } => chip !== null); const firstVisibleBlockerChip = visibleBlockerChips[0] ?? null; const additionalVisibleBlockerCount = Math.max(visibleBlockerChips.length - 1, 0); const additionalVisibleBlockerLabel = additionalVisibleBlockerCount > 0 ? ` ... and ${additionalVisibleBlockerCount} more` : ""; const firstVisibleBlockerDisplayLabel = firstVisibleBlockerChip ? `${firstVisibleBlockerChip.chipLabel}${additionalVisibleBlockerLabel}` : ""; const hiddenVisibleBlockerLabels = visibleBlockerChips .slice(1) .map((chip) => chip.chipLabel) .join(", "); const firstVisibleBlockerTitle = additionalVisibleBlockerCount > 0 ? `${firstVisibleBlockerDisplayLabel}: ${hiddenVisibleBlockerLabels}` : firstVisibleBlockerDisplayLabel; const checklistDependencyChips = checklistMeta && firstVisibleBlockerChip ? ( ) : null; return (
0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined} style={useDeferredRowRendering ? { contentVisibility: "auto", containIntrinsicSize: "44px", } : undefined} > setNavSelectionFromPointer(`issue:${issue.id}`)} treeGuides={depth} chevronInGuide={depth > 0 && hasChildren} checklistStepNumber={checklistStepNumber} checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id} checklistDependencyChips={checklistDependencyChips} checklistRowId={checklistRowId} titleClassName={doneRowTitleClass} externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null} titleSuffix={( <> {hasChildren && !isExpanded ? ( {enableAgentCreatedVisibilityFilter ? "(sub-tasks)" : `(${totalDescendants} sub-task${totalDescendants !== 1 ? "s" : ""})`} ) : null} {issueBadge ? ( issueBadge === "Paused" ? ( Paused ) : ( {issueBadge} ) ) : null} {isSuccessfulRunHandoffRequired(issue) ? ( Needs next step ) : null} )} className={cn(isMutedIssue && "opacity-70", selectedNavKey === `issue:${issue.id}` && "bg-accent/50 hover:bg-accent/50")} leadingControl={rowPresentation === "task" ? ( hasChildren ? ( ) : (
); }; const separatorField = viewState.showDateGroupSeparators ? issueDateSeparatorField(viewState) : null; const separatorNow = new Date(); const nodes: ReactNode[] = []; let previousDateGroup: TaskDateGroup | null = null; let previousAgeBucket: 0 | 1 | 2 | null = null; const appendIssueRow = (issue: Issue, depth: number) => { const node = renderIssueRow(issue, depth); // Skip rows the render budget dropped so separators never // dangle above an unrendered (or absent) row. if (node === null) return; if (separatorField && rowPresentation === "task") { const currentDateGroup = taskDateGroup(issue[separatorField], separatorNow); const separatorLabel = taskDateGroupSeparator(previousDateGroup, currentDateGroup); if (separatorLabel) { nodes.push( , ); } previousDateGroup = currentDateGroup; } else if (separatorField) { const currentAgeBucket = issueAgeBucket(issue[separatorField], separatorNow.getTime()); for (const crossedBucket of previousAgeBucket === null ? [] : issueAgeBucketsCrossed(previousAgeBucket, currentAgeBucket)) { nodes.push( , ); } previousAgeBucket = currentAgeBucket; } nodes.push(node); if (!viewState.collapsedParents.includes(issue.id)) { for (const child of childMap.get(issue.id) ?? []) { appendIssueRow(child, depth + 1); } } }; for (const issue of roots) { appendIssueRow(issue, 0); } return nodes; })()}
); })} {(remainingIssueRowCount > 0 || hasMoreHierarchyRows || hasMoreIssues || isLoadingMoreIssues) && (

{isLoadingMoreIssues || (hierarchyEnabled && hierarchy.isFetching) ? "Loading more tasks..." : remainingIssueRowCount > 0 ? `Rendering ${Math.min(renderedIssueRowLimit, visibleListRowCount)} of ${visibleListRowCount} tasks` : "Scroll to load more tasks"}

)}
))} ); }