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 { collectSubtreeLiveCounts } from "../lib/liveIssueIds"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, 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 } from "./IssueRow"; 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 { useGeneralSettings } from "../context/GeneralSettingsContext"; 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 { 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; collapsedGroups: string[]; collapsedParents: string[]; boardCardDensity: BoardCardDensity; boardColdLaneMode: BoardColdLaneMode; boardColumnPageSize: BoardColumnPageSize; }; const defaultViewState: IssueViewState = { ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", viewMode: "list", nestingEnabled: 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 getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); if (raw) { const parsed = JSON.parse(raw); return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed), boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity), boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode), boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize), }; } } catch { /* ignore */ } return { ...defaultViewState }; } function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } function getInitialViewState( key: string, initialAssignees?: string[], defaultSortField?: IssueSortField, ): IssueViewState { const hasStored = hasStoredViewState(key); const stored = getViewState(key); const base = !hasStored && defaultSortField ? { ...stored, sortField: defaultSortField, sortDir: "asc" as const } : stored; if (!initialAssignees) return base; return { ...base, assignees: initialAssignees, statuses: [], }; } function getInitialWorkspaceViewState( key: string, initialAssignees?: string[], initialWorkspaces?: string[], defaultSortField?: IssueSortField, ): IssueViewState { const stored = getInitialViewState(key, initialAssignees, defaultSortField); if (!initialWorkspaces) return stored; return { ...stored, workspaces: initialWorkspaces, statuses: [], }; } function hasStoredViewState(key: string): boolean { try { return localStorage.getItem(key) !== null; } catch { return false; } } function getIssueColumnsStorageKey(key: string): string { return `${key}:issue-columns`; } function loadIssueColumns(key: string): InboxIssueColumn[] { try { const raw = localStorage.getItem(getIssueColumnsStorageKey(key)); if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS; return normalizeInboxIssueColumns(parsed); } catch { return DEFAULT_INBOX_ISSUE_COLUMNS; } } function saveIssueColumns(key: string, columns: InboxIssueColumn[]) { try { localStorage.setItem( getIssueColumnsStorageKey(key), JSON.stringify(normalizeInboxIssueColumns(columns)), ); } catch { // Ignore localStorage failures. } } 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; } const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000; const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS; // Recency buckets for the date separators shown in date-sorted lists: // 0 = within the last day, 1 = within the last week, 2 = older than a week. // A separator is drawn between rows whenever the bucket increases, so the // "1 day" and "1 week" boundaries appear as the list ages downward. 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; } // 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; } function IssueDateSeparator({ label }: { label: string }) { return (
{label}
); } 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[]; 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; enableRoutineVisibilityFilter?: boolean; hasMoreIssues?: boolean; isLoadingMoreIssues?: boolean; mutedIssueIds?: Set; issueBadgeById?: Map; onLoadMoreIssues?: () => void; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } 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({ issues, isLoading, error, agents, projects, liveIssueIds, projectId, viewStateKey, issueLinkState, initialAssignees, initialWorkspaces, initialSearch, searchFilters, searchWithinLoadedIssues = false, baseCreateIssueDefaults, createIssueLabel, defaultSortField, showProgressSummary = false, parentIssueIdForCostSummary, enableRoutineVisibilityFilter = false, hasMoreIssues = false, isLoadingMoreIssues = false, mutedIssueIds, issueBadgeById, onLoadMoreIssues, onSearchChange, onUpdateIssue, }: IssuesListProps) { const rootRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); const { keyboardShortcutsEnabled } = useGeneralSettings(); // 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 initialAssigneesKey = initialAssignees?.join("|") ?? ""; const initialWorkspacesKey = initialWorkspaces?.join("|") ?? ""; const [viewState, setViewState] = useState(() => getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField), ); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => loadIssueColumns(scopedKey)); 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}`); useEffect(() => { const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`; if (prevViewStateContextKey.current !== nextContextKey) { prevViewStateContextKey.current = nextContextKey; setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField)); } }, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, defaultSortField]); const prevColumnsScopedKey = useRef(scopedKey); useEffect(() => { if (prevColumnsScopedKey.current !== scopedKey) { prevColumnsScopedKey.current = scopedKey; setVisibleIssueColumns(loadIssueColumns(scopedKey)); } }, [scopedKey]); const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; saveViewState(scopedKey, next); return next; }); }, [scopedKey]); 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(() => { 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 ?? {}, "compact", ISSUE_SEARCH_RESULT_LIMIT, enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { q: normalizedIssueSearch, projectId, limit: ISSUE_SEARCH_RESULT_LIMIT, ...searchFilters, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }, { 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, enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], queryFn: ({ signal }: { signal: AbortSignal }) => issuesApi.listCompact(selectedCompanyId!, { ...searchFilters, ...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}), projectId, status, limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }, { 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 workspaceOptions = useMemo(() => { const options = new Map(); for (const [workspaceId, workspaceName] of workspaceNameMap) { options.set(workspaceId, workspaceName); } return [...options.entries()] .sort((a, b) => a[1].localeCompare(b[1])) .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); 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)), [availableIssueColumnSet, 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, } = useIssueExternalObjectSummaries( selectedCompanyId, issueIdsForExternalObjectSummaries, ); const issueFilterContext = useMemo(() => ({ ...issueFilterWorkspaceContext, externalObjectSummaryByIssueId, externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]); const externalObjectFilterLoading = hasExternalObjectStatusFilters && externalObjectSummariesLoading && !externalObjectSummariesReady; const filtered = useMemo(() => { const filteredByControls = applyIssueFilters( searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter, liveIssueIds, issueFilterContext, ); return sortIssues(filteredByControls, viewState); }, [ searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter, 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, enableRoutineVisibilityFilter); 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 groupedContent = 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, ]); // 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 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(":scope > [data-inbox-issue-link]"); return link instanceof HTMLElement ? link : null; }, []); useEffect(() => { if (!keyboardShortcutsEnabled) return; 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); }, [keyboardShortcutsEnabled, 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; setRenderedIssueRowLimit((current) => { const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT); const listAppended = previousIssueIds.length > 0 && nextIssueIds.startsWith(previousIssueIds) && filtered.length >= current; if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit)); return nextInitialLimit; }); }, [filtered, viewState.viewMode]); const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length; const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); const loadMoreIssueRows = useCallback(() => { if (viewState.viewMode !== "list") return; if (hasMoreRenderedRows) { setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE)); return; } if (hasMoreIssues && !isLoadingMoreIssues) { onLoadMoreIssues?.(); } }, [ filtered.length, hasMoreIssues, hasMoreRenderedRows, isLoadingMoreIssues, onLoadMoreIssues, viewState.viewMode, ]); const canLoadMoreIssues = viewState.viewMode === "list" && !isLoading && (hasMoreRenderedRows || (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); saveIssueColumns(scopedKey, normalized); }, [scopedKey]); 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; return (
{progressSummary ? ( ) : null} {/* Toolbar */}
{ setIssueSearch(nextSearch); onSearchChange?.(nextSearch); }} />
{/* View mode toggle */}
{viewState.viewMode === "list" && ( )} {viewState.viewMode === "board" && ( <>
{KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => ( ))}
)} setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which task columns stay visible" iconOnly /> ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} /> {/* Sort (list view only) */} {viewState.viewMode === "list" && (
{([ ["workflow", "Workflow"], ["status", "Status"], ["priority", "Priority"], ["title", "Title"], ["created", "Created"], ["updated", "Updated"], ] as const).map(([field, label]) => ( ))}
)} {/* Group (list view only) */} {viewState.viewMode === "list" && (
{([ ["status", "Status"], ["priority", "Priority"], ["assignee", "Responsible"], ["project", "Project"], ["workspace", "Workspace"], ["parent", "Parent Task"], ["none", "None"], ] as const).map(([value, label]) => ( ))}
)}
{(isLoading || externalObjectFilterLoading) && } {error &&

{error.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.

)} {!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && ( openCreateIssueDialog()} /> )} {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) => { 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} hideDivider={hasChildren && isExpanded} checklistStepNumber={checklistStepNumber} checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id} checklistDependencyChips={checklistDependencyChips} checklistRowId={checklistRowId} titleClassName={doneRowTitleClass} externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null} titleSuffix={( <> {hasChildren && !isExpanded ? ( ({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")} mobileLeading={ hasChildren ? ( ) : ( { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> ) } desktopMetaLeading={( <> {hasChildren ? ( ) : ( )} { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> )} /> )} mobileMeta={issueActivityText(issue).toLowerCase()} desktopTrailing={( visibleTrailingIssueColumns.length > 0 ? ( { setAssigneePickerIssueId(open ? issue.id : null); if (!open) setAssigneeSearch(""); }} > e.stopPropagation()} onPointerDownOutside={() => setAssigneeSearch("")} > setAssigneeSearch(e.target.value)} autoFocus />
{currentUserId && ( )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); }) .map((agent) => ( ))}
)} /> ) : undefined )} />
); }; const separatorField = issueDateSeparatorField(viewState); const separatorNow = Date.now(); const nodes: ReactNode[] = []; let prevBucket: 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) { const bucket = issueAgeBucket(issue[separatorField], separatorNow); for (const crossedBucket of prevBucket === null ? [] : issueAgeBucketsCrossed(prevBucket, bucket)) { nodes.push( , ); } prevBucket = bucket; } 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 || hasMoreIssues || isLoadingMoreIssues) && (

{isLoadingMoreIssues ? "Loading more tasks..." : remainingIssueRowCount > 0 ? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} tasks` : "Scroll to load more tasks"}

)} )}
); }