Files
PaperClipAI/ui/src/components/IssuesList.tsx
T
DottaandPaperclip 5061ab7b35 perf(ui): speed up task and inbox cold opens (#17756)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task lists and inboxes are the main entry points to that work.
> - Cold opens fetch overlapping task lists, task descriptions, and run
details before useful rows settle.
> - Independent responses can insert rows above content that a person
has started to read.
> - This pull request loads a small task index first and adds details
only for visible rows.
> - It keeps row order and geometry stable while those details arrive.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Cold opens of Tasks and Inbox, including the shared navigation badge.

**Subsystem affected**

Server list reads, shared response contracts, and the React UI.

**Current behavior**

The badge fetches full task bodies. Mine fetches several overlapping
lists. Historical diagnostics and row metadata add work before the first
useful list. A fast failed-run response can appear before task rows,
then move down when tasks arrive.

**Proposed behavior**

Load the selected inbox source and its required context together. Use an
index without task descriptions or recovery diagnostics. Fetch
diagnostic details for up to 50 visible rows. Keep skeletons, recovery
indicators, external status, and live metadata in fixed space.

**Reason and benefit**

Reduce work on a cold open and prevent loading-induced row movement. On
the same 100 local fixture tasks, the compact response was 262,441
decoded bytes and the index was 110,909 bytes. This comparison excludes
compression and is not a page latency benchmark.

**Breaking changes**

Existing full and compact responses remain available. New index/row
projections are opt-in and marked as partial data. Mine keeps its
existing most-recent-100 display limit, now selected on the server.
Existing filters and group preferences remain in the client. No database
migration.

**Additional context**

Refs: #17593. Related: #13095, #17428, #13803. This change addresses
collection overfetch and layout stability. It does not replace the
blocked-inbox query work or the database activity-index work.

## What Changed

- Add authorized index and row projections, with bounded task and parent
ID batches.
- Replace badge list downloads with unread IDs and aggregate counts.
Preserve actor privacy, dismissals, and local archive behavior.
- Load only the selected inbox source, plus bounded parent and
failed-run context. Return only relevant failures from the bounded run
window.
- Batch hierarchy reads, including at most one authorized child per
collapsed parent in one request. Collapsed markers do not consume the
expanded row budget, and partial descendant counts are not shown as
totals. Enrich visible rows without replacing membership, ordering,
titles, or read state.
- Reserve row geometry on desktop and mobile. Reveal the initial inbox
only after its sources settle. Preserve legacy task-list reads.
- Start collection module downloads during access checks. Keep partial
rows out of the task-detail cache.
- Keep ETags and concurrent request sharing for the new projections, but
skip the short server response TTL so archive undo sees fresh state.

## Verification

- On final head `ef6afb2a56`, the full CI workflow passed, including
build, typecheck, server/workspace/runner suites, and all eight browser
shards. A server shard reported **1,489 passing tests** before runner
shutdown canceled the job; its single infrastructure retry passed. Both
Greptile and Paperclip Review are **5/5**, with zero unresolved review
threads.
- The optional external **Entire Gates** check remains `unassessed`
because it has no active assessed Change for this comparison. Entire is
not set up in this checkout. The repository ruleset requires `ci /
verify` and `ci / e2e`; both passed. No gate configuration was changed.

- Passed workspace typecheck after the final API batching change. The
full production build also passed on the final commit; the exact-retry
E2E run built the shipped UI.
- Passed token gates and map validation. The final batch-preview pass
has 91 passing route/hierarchy/component tests, including privacy,
request bounds, 30 collapsed parents in one read, and a 120-child parent
alongside a sibling.
- Passed the 166-test focused UI/inbox/badge run, 47 additional UI
regression tests, and 34 issue-list route tests. The latest regressions
cover low-trust agent badge IDs, cold company selection, 30
description-only Mine matches with archive actions, retrying optional
row details, and collapsed/expanded siblings.
- The full local `pnpm test:run` spanned the rebase and reported
failures in five suites before it was stopped; it is not claimed as a
complete pass. A fresh serial rerun of all five suites passed
**452/452** tests (tool gateway, native resume, project repositories,
workspace recovery, and runner selection). Full current-commit coverage
is checked by CI.
- Real browser: delayed index and row-detail responses on Tasks and
Mine. The first placeholder and first row had the same origin and
44-pixel height. Twelve rows on each page had zero position, height, or
title-width change after recovery details arrived.
- Real browser: reviewed long titles and recovery status at a 390 by 844
viewport. Task text stays on one line and recovery uses a compact
indicator.
- Three fresh document loads per page on final commit `260893bf92`, with
HTTP cache retained on a warm local development server: Tasks
1.031–1.190 seconds; Mine 0.999–1.193 seconds. Browser-control overhead
is included. These values must not be compared directly with staging
latency or the earlier local samples taken during a full test run.
- The browser archive test exposed stale results after Undo. The new
projections now bypass the server TTL; immediate-mutation regression
tests pass. The final browser retry passed: Archive followed by Undo
restored exactly one row and the badge count.

- CI exposed a hook-order crash when company selection resolved after
the first inbox render. The hook now runs before the empty-company
return. All seven affected exact-retry and legacy-continuation browser
scenarios pass locally after the fix.
- Real browser review regressions: 97 description-only source matches
retained Archive actions; deliberately failed optional row reads
displayed a retry notice while all 12 observed row positions and heights
stayed unchanged. Retry restored the recovery indicator. Network fault
injection was removed.

- The final production build and workspace typecheck passed. Final
focused verification passed 153 UI/helper tests and 26 heartbeat privacy
route tests. Final review regressions cover newer diagnostics arriving
during the index read, reloading diagnostics when the same task ID
changes version, optional source failure/retry without hiding available
rows, and required external-status filter failure. The latest-failures
route test verifies bounded selection, agent filtering, success
suppressing an older failure, and private-run redaction.
- Real browser: deliberately failed the run source, then retried it.
Twelve available task rows kept the same position and 44-pixel height
before, during, and after retry. No loading skeleton replaced them.
Fault injection was removed.
- Hierarchy review decision: the cold-open barrier stays because
inserting ancestors or expanded children after revealing index rows
would cause layout shifts. Existing hierarchy context now survives
subsequent requests, with a regression proving a new ancestor read does
not blank the list. The bounded recursive tree read can still add
latency for unusually deep expanded trees.

- Tasks now handles a failed external-status filter with an explicit
error and Retry, suppressing a false empty state. All 52 Tasks component
tests pass, including failure-to-success retry. One existing large-tree
test timed out while workspace typechecking ran concurrently; the whole
component suite passed when rerun without that competing work. Workspace
typecheck, the full production build, and token gates passed for this
fix.

## Risks

- Saved filters and grouping still operate on the existing bounded
collection. This is not a new global server pagination model.
- Fixed row slots reduce available title width. Long titles remain
accessible through task navigation.
- Recovery and related diagnostics arrive after core rows. Their
responses cannot replace ordering fields, and stale versions are
rejected.
- Index/row requests now revalidate against current data instead of a
short server TTL. They still share concurrent reads and use ETags.
- This has not been deployed by this pull request. Staging latency must
be measured after deployment under comparable conditions.

## Model Used

OpenAI Codex, GPT-6 family. The exact runtime model ID and
context-window size are not exposed in this session. Used code editing,
terminal execution, and real-browser diagnostics.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-26 14:29:20 -05:00

2599 lines
115 KiB
TypeScript

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<IssueStatus, string> = {
backlog: "Backlog",
todo: "Todo",
in_progress: "In progress",
in_review: "In review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
};
const progressSegmentClasses: Record<IssueStatus, string> = {
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<IssueViewState> : {};
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<IssueViewState, InboxIssueColumn>({
...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 (
<div
className="flex items-center gap-3 px-3 py-1.5 sm:pl-0 sm:pr-4"
role="separator"
aria-label={label}
data-issues-date-separator=""
>
<span className="h-px min-w-0 flex-1 bg-border/80" aria-hidden="true" data-date-group-rule="" />
<span
className="shrink-0 text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground/70"
data-date-group-label=""
>
{label}
</span>
<span className="h-px min-w-0 flex-1 bg-border/80" aria-hidden="true" data-date-group-rule="" />
</div>
);
}
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<string, string> {
const stepNumberByIssueId = new Map<string, string>();
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<string, string> {
const previousSiblingByIssueId = new Map<string, string>();
if (!nestingEnabled) {
const previousByParentId = new Map<string, Issue>();
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<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
interface IssuesListProps {
issues: Issue[];
isLoading?: boolean;
error?: Error | null;
agents?: Agent[];
projects?: ProjectOption[];
liveIssueIds?: Set<string>;
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<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
baseCreateIssueDefaults?: Record<string, unknown>;
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<string>;
issueBadgeById?: Map<string, string>;
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<string, unknown>) => void;
}
function LegacyIssuesToolbar({ context, search, controls }: CollectionToolbarProps) {
return (
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
{context}
{search}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
{controls}
</div>
</div>
);
}
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 (
<div className="relative w-full sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftValue}
onChange={(e) => {
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"
/>
</div>
);
}
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 (
<div className="border border-border bg-background p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="font-medium text-foreground">
{summary.doneCount}/{summary.totalCount} done
</span>
<span className="text-muted-foreground">
{summary.inProgressCount} in progress
</span>
<span className="text-muted-foreground">
{summary.blockedCount} blocked
</span>
{showCostSummary && (
<>
<span
className="text-muted-foreground tabular-nums"
title={`${costSummary.runCount.toLocaleString()} run${
costSummary.runCount === 1 ? "" : "s"
} across ${costSummary.issueCount} sub-task${
costSummary.issueCount === 1 ? "" : "s"
}`}
>
{formatTokens(totalTokens)} tokens
</span>
<span className="text-muted-foreground tabular-nums">
{formatDurationMs(costSummary.runtimeMs)} runtime
</span>
</>
)}
</div>
<div
role="progressbar"
aria-label="Sub-tasks completion progress"
aria-valuemin={0}
aria-valuenow={summary.doneCount}
aria-valuemax={summary.totalCount}
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
>
{statusEntries.map(({ status, count }) => (
<span
key={status}
className={cn("h-full", progressSegmentClasses[status])}
style={{ width: `${(count / summary.totalCount) * 100}%` }}
title={`${issueStatusLabels[status]}: ${count}`}
aria-hidden="true"
/>
))}
</div>
</div>
<div className="min-w-0 border border-border bg-background px-3 py-2 text-sm lg:w-72">
{target && targetIssue ? (
<>
<div className="text-xs font-medium text-muted-foreground">
{target.kind === "next" ? "Next up" : "Waiting on blockers"}
</div>
<Link
to={createIssueDetailPath(targetPathId)}
state={targetState}
issuePrefetch={targetIssue}
className="mt-1 block min-w-0 text-foreground underline-offset-2 hover:underline"
>
<span className="font-mono text-xs text-muted-foreground">
{targetIssue.identifier ?? targetIssue.id.slice(0, 8)}
</span>{" "}
<span>{targetIssue.title}</span>
</Link>
</>
) : summary.totalCount === 0 ? (
<div className="text-sm font-medium text-foreground">No active sub-tasks</div>
) : summary.doneCount === summary.totalCount ? (
<div className="text-sm font-medium text-foreground">All sub-tasks done</div>
) : (
<div className="text-sm font-medium text-foreground">No actionable sub-tasks</div>
)}
</div>
</div>
</div>
);
}
// 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 ? <StreamlinedIssuesList {...props} /> : <LegacyIssuesList {...props} />;
}
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<HTMLDivElement | null>(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<string | null>(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<string | null>(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<ReturnType<typeof loadIssueCollectionPreferences> | null>(null);
if (initialPreferencesRef.current === null) {
initialPreferencesRef.current = loadIssueCollectionPreferences(preferenceLocation);
}
const initialPreferences = initialPreferencesRef.current;
const [viewState, setViewState] = useState<IssueViewState>(() =>
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<string | null>(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<InboxIssueColumn[]>(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<IssueViewState>) => {
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<string, { name: string; color: string | null }>();
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<string, { name: string; projectId: string }>();
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<string, string>();
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<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId: string | null;
}>();
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<string, string>();
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<CreatorOption[]>(() => {
const options = new Map<string, CreatorOption>();
const knownAgentIds = new Set<string>();
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<string>()),
[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<string, Issue>();
for (const issue of issues) {
map.set(issue.id, issue);
}
return map;
}, [issues]);
const issueTitleMap = useMemo(() => {
const map = new Map<string, string>();
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<string, Issue>();
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<string, string[]>();
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<string, Issue[]>() };
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<string, unknown> = { ...(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 (
<div ref={rootRef} className="space-y-4">
<IssueRowDetailsNotice error={rowDetailsError} onRetry={() => void retryRowDetails()} />
{progressSummary ? (
<SubIssueProgressSummaryStrip
summary={progressSummary}
issueLinkState={issueLinkState}
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
/>
) : null}
{/* Toolbar */}
<IssuesToolbar
className="paperclip-task-list-toolbar"
ariaLabel={toolbarPresentation === "collection" ? "Task controls" : undefined}
context={toolbarContext ? (
<div className="flex min-w-0 items-center gap-2">
{toolbarContext}
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
</div>
) : (
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
)}
search={(
<IssueSearchInput
value={issueSearch}
onDebouncedChange={(nextSearch) => {
setIssueSearch(nextSearch);
onSearchChange?.(nextSearch);
}}
/>
)}
controls={(
<>
{/* View mode toggle */}
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1" role="group" aria-label="View mode">
<button
className={`flex h-8 w-8 items-center justify-center transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "list" })}
title="List view"
aria-label="List view"
aria-pressed={viewState.viewMode === "list"}
>
<List className="h-3.5 w-3.5" />
</button>
<button
className={`flex h-8 w-8 items-center justify-center transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "board" })}
title="Board view"
aria-label="Board view"
aria-pressed={viewState.viewMode === "board"}
>
<SquareKanban className="h-3.5 w-3.5" />
</button>
</div>
{viewState.viewMode === "list" && (
<Button
type="button"
variant="outline"
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", viewState.nestingEnabled && "bg-accent")}
onClick={() => updateView({ nestingEnabled: !viewState.nestingEnabled })}
title={viewState.nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
<ListTree className="h-3.5 w-3.5" />
</Button>
)}
{viewState.viewMode === "board" && (
<>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", boardCompactCards && "bg-accent")}
onClick={() => updateView({ boardCardDensity: boardCompactCards ? "comfortable" : "compact" })}
title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", boardCollapsedStatuses.length > 0 && "bg-accent")}
onClick={() => updateView({ boardColdLaneMode: boardCollapsedStatuses.length > 0 ? "expanded" : "collapsed" })}
title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}
>
<PanelTopClose className="h-3.5 w-3.5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 shrink-0 gap-1.5 px-2",
viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE && "bg-accent",
)}
title="Cards per column"
>
<ListCollapse className="h-3.5 w-3.5" />
<span className="min-w-4 text-xs tabular-nums">{viewState.boardColumnPageSize}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-0">
<div className="p-2 space-y-0.5">
{KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => (
<button
key={pageSize}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
viewState.boardColumnPageSize === pageSize
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => updateView({ boardColumnPageSize: pageSize })}
>
<span>{pageSize} per column</span>
{viewState.boardColumnPageSize === pageSize && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => updateView({
boardCardDensity: "auto",
boardColdLaneMode: "expanded",
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
})}
disabled={!boardDensityCustomized}
title="Reset board density"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</>
)}
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
showDateGroupSeparators={viewState.showDateGroupSeparators}
onToggleDateGroupSeparators={(enabled) => updateView({ showDateGroupSeparators: enabled })}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which task columns stay visible"
iconOnly
rowPresentation={rowPresentation}
/>
<IssueFiltersPopover
state={viewState}
onChange={updateView}
buttonVariant="outline"
activeFilterCount={activeFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ 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" && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0">
<div className="p-2 space-y-0.5">
{/* 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]) => (
<button
key={field}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.sortField === field ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => {
if (viewState.sortField === field) {
updateView({ sortDir: viewState.sortDir === "asc" ? "desc" : "asc" });
} else {
updateView({ sortField: field, sortDir: "asc" });
}
}}
>
<span>{label}</span>
{viewState.sortField === field && (
<span className="text-xs text-muted-foreground">
{viewState.sortDir === "asc" ? "\u2191" : "\u2193"}
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
{/* Group (list view only) */}
{viewState.viewMode === "list" && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Group">
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{/* 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]) => (
<button
key={value}
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
viewState.groupBy === value ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
}`}
onClick={() => updateView({ groupBy: value })}
>
<span>{label}</span>
{viewState.groupBy === value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</>
)}
/>
{collectionLoading && !externalObjectFilterError && <PageSkeleton variant="issues-list" collectionHeading={viewState.groupBy !== "none" ? "group"
: viewState.showDateGroupSeparators && issueDateSeparatorField(viewState) && rowPresentation === "task" ? "date" : null} />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{externalObjectFilterError && (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<p>Unable to filter tasks by external status: {externalObjectFilterError.message}</p>
<Button variant="outline" size="sm" onClick={retryExternalObjectSummaries}>Retry external statuses</Button>
</div>
)}
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
</p>
)}
{boardColumnLimitReached && (
<p className="text-xs text-muted-foreground">
Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.
</p>
)}
{hierarchy.error ? <p className="text-sm text-destructive">Failed to load task hierarchy: {hierarchy.error.message}</p> : null}
{!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}
message="No tasks match the current filters or search."
action={createActionLabel}
onAction={() => openCreateIssueDialog()}
/>
)}
{!collectionLoading && !externalObjectFilterError && (viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
compactCards={boardCompactCards}
collapsedStatuses={boardCollapsedStatuses}
initialVisibleCount={viewState.boardColumnPageSize}
revealIncrement={viewState.boardColumnPageSize}
onUpdateIssue={onUpdateIssue}
/>
) : (
<div className="-mx-2 sm:mx-0">
{groupedContent.map((group) => {
if (remainingRowsToRender <= 0) return null;
return (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
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.
<div
data-issues-group-key={group.key}
className={cn("rounded-lg px-3 sm:pl-0 sm:pr-4", selectedNavKey === `group:${group.key}` ? "bg-accent/50" : "hover:bg-accent/50")}
onClick={() => setSelectedNavKey(`group:${group.key}`)}
onMouseEnter={() => setNavSelectionFromPointer(`group:${group.key}`)}
>
<IssueGroupHeader
label={group.label}
collapsible
collapsed={viewState.collapsedGroups.includes(group.key)}
onToggle={() => {
updateView({
collapsedGroups: viewState.collapsedGroups.includes(group.key)
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
trailing={(
<Button
variant="ghost"
size="icon-xs"
className="-mr-2 text-muted-foreground"
title={`New task in ${group.label}`}
aria-label={`New task in ${group.label}`}
onClick={() => openCreateIssueDialog(group)}
>
<Plus className="h-3 w-3" />
</Button>
)}
/>
</div>
)}
<CollapsibleContent>
{(() => {
const { roots, childMap } = viewState.nestingEnabled
? buildIssueTree(group.items)
: { roots: group.items, childMap: new Map<string, Issue[]>() };
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 ? (
<button
key={firstVisibleBlockerChip.blockerId}
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const target = document.getElementById(`issue-workflow-row-${firstVisibleBlockerChip.blockerId}`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
target.focus?.();
}}
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-(length:--text-nano) font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title={firstVisibleBlockerTitle}
aria-label={firstVisibleBlockerTitle}
>
{firstVisibleBlockerDisplayLabel}
</button>
) : null;
return (
<div
key={issue.id}
data-issue-row-id={issue.id}
// Canonical rows use the same tree-guide slots at every width.
className={rowPresentation === "legacy" && depth > 0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined}
style={useDeferredRowRendering
? {
contentVisibility: "auto",
containIntrinsicSize: "44px",
}
: undefined}
>
<IssueRow
issue={issue}
presentation={rowPresentation}
stableLayout={enableAgentCreatedVisibilityFilter}
issueLinkState={issueLinkState}
selected={selectedNavKey === `issue:${issue.id}`}
onMouseEnter={() => 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 ? (
<span className="ml-1.5 text-xs text-muted-foreground">
{enableAgentCreatedVisibilityFilter
? "(sub-tasks)"
: `(${totalDescendants} sub-task${totalDescendants !== 1 ? "s" : ""})`}
</span>
) : null}
{issueBadge ? (
issueBadge === "Paused" ? (
<Badge variant="ghost"
className={cn("ml-1.5 px-1.5 text-(length:--text-nano)", statusBadge.paused)}
aria-label="Paused"
title="Paused"
>
<CircleSlash2 className="h-3 w-3" />
Paused
</Badge>
) : (
<Badge variant="outline" className="ml-1.5 border-amber-500/40 bg-amber-500/10 px-1.5 text-(length:--text-nano) text-amber-700 dark:text-amber-300">
{issueBadge}
</Badge>
)
) : null}
{isSuccessfulRunHandoffRequired(issue) ? (
<Badge variant="outline"
className="ml-1.5 border-amber-400/45 bg-amber-50/60 px-1.5 text-(length:--text-nano) text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
aria-label="Needs next step"
title="This task needs a next step"
>
<CircleDot className="h-3 w-3" />
Needs next step
</Badge>
) : null}
</>
)}
className={cn(isMutedIssue && "opacity-70", selectedNavKey === `issue:${issue.id}` && "bg-accent/50 hover:bg-accent/50")}
leadingControl={rowPresentation === "task" ? (
hasChildren ? (
<button
type="button"
data-slot="icon-button"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center"
aria-label={isExpanded ? "Collapse sub-tasks" : "Expand sub-tasks"}
onClick={toggleCollapse}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span data-slot="task-row-disclosure-spacer" className="h-4 w-4 shrink-0" aria-hidden="true" />
)
) : undefined}
statusSlot={rowPresentation === "task" ? (
<span className="relative inline-flex items-start self-stretch sm:items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
{hasChildren && isExpanded ? (
<span aria-hidden="true" className="pointer-events-none absolute top-5 -bottom-2.5 left-1/2 w-px bg-border sm:hidden" />
) : null}
</span>
) : undefined}
metadata={rowPresentation === "task" ? (
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds?.has(issue.id) === true}
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
showStatus={false}
showIdentifier={false}
checklistStepNumber={checklistStepNumber}
/>
) : undefined}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
mobileLeading={
hasChildren ? (
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
desktopMetaLeading={rowPresentation === "legacy" ? (
<>
{hasChildren ? (
<button
type="button"
data-slot="icon-button"
className="relative z-10 hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={toggleCollapse}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="hidden w-4 shrink-0 sm:block" />
)}
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds?.has(issue.id) === true}
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
</>
) : undefined}
mobileTitleMeta={rowPresentation === "task" ? issueActivityTimestamp(issue) : undefined}
mobileMeta={rowPresentation === "legacy" ? issueActivityText(issue).toLowerCase() : undefined}
trailingMeta={rowPresentation === "task"
&& visibleIssueColumnSet.has("updated")
&& availableIssueColumnSet.has("updated")
? issueActivityTimestamp(issue)
: undefined}
desktopTrailing={(
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
issue={issue}
columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null}
workspaceId={resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext)}
workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
onFilterWorkspace={filterToWorkspace}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={assigneeUserLabel}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
creatorAgentName={agentName(issue.createdByAgentId)}
creatorUserName={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.label ?? null) : null}
creatorUserAvatarUrl={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.image ?? null) : null}
viaAgentName={originatingViaAgentId ? agentName(originatingViaAgentId) : null}
currentUserId={currentUserId}
parentIdentifier={parentIssue?.identifier ?? null}
parentTitle={parentIssue?.title ?? null}
assigneeContent={(
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-full shrink-0 items-center overflow-hidden rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<AgentIdentity agent={agents!.find((agent) => agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
<Identity
name={assigneeUserLabel ?? "User"}
avatarUrl={assigneeUserProfile?.image ?? null}
size="sm"
className="min-w-0"
/>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3.5 w-3.5" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search responsible..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No responsible
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<AgentIdentity agent={agent} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
/>
) : undefined
)}
/>
</div>
);
};
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(
<IssueDateSeparator
key={`date-sep-${issue.id}-${currentDateGroup}`}
label={separatorLabel}
/>,
);
}
previousDateGroup = currentDateGroup;
} else if (separatorField) {
const currentAgeBucket = issueAgeBucket(issue[separatorField], separatorNow.getTime());
for (const crossedBucket of previousAgeBucket === null
? []
: issueAgeBucketsCrossed(previousAgeBucket, currentAgeBucket)) {
nodes.push(
<IssueDateSeparator
key={`date-sep-${issue.id}-${crossedBucket}`}
label={issueAgeSeparatorLabel(crossedBucket)}
/>,
);
}
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;
})()}
</CollapsibleContent>
</Collapsible>
);
})}
{(remainingIssueRowCount > 0 || hasMoreHierarchyRows || hasMoreIssues || isLoadingMoreIssues) && (
<div className="py-2" data-testid="issues-load-more-sentinel">
<p className="text-xs text-muted-foreground">
{isLoadingMoreIssues || (hierarchyEnabled && hierarchy.isFetching)
? "Loading more tasks..."
: remainingIssueRowCount > 0
? `Rendering ${Math.min(renderedIssueRowLimit, visibleListRowCount)} of ${visibleListRowCount} tasks`
: "Scroll to load more tasks"}
</p>
</div>
)}
</div>
))}
</div>
);
}