mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from task lists, the Inbox and the sidebar many times each hour > - A task's chat shows its first frame only after about seven separate reads finish > - #20355 added one batched `chat-bootstrap` read that returns all of these reads. #20450 used it only for the sidebar's Recents > - The hosted database is CPU-bound, so the UI must open tasks faster and must also send fewer requests > - This pull request uses the bootstrap for task lists, for open intent on any task link, for cold opens and for a hard load of a task URL. It shares one coordination module between all of these paths > - The benefit is that a task opens from cache more often, a cold open sends one chat request instead of about seven, and the first content paint comes sooner ## Linked Issues or Issue Description Refs #20355 (server read that this pull request consumes). Refs #20450 (Recents preload, now on the shared path). **Is your feature request related to a problem?** A click on a task in a task list waited for about seven separate chat reads. The default task list (Coordinator) had no preload at all. A press or a resting hover on a task link sent the same seven reads, and the page then sent a second set. A hard load of a task URL started its chat reads only after the JavaScript bundle and the company list loaded. Sidebar project task links had no open-intent prefetch. **Describe the solution you'd like** Use the single `chat-bootstrap` read everywhere a task is about to open. Let the page's gating reads share a bootstrap that is in flight instead of sending their own requests. Keep the core issue read separate so the header does not wait for the larger read. **Describe alternatives you've considered** Preload 30 rows per list in three requests (the first prototype). Each task costs the server about fifty queries, and a resting hover warms any other row before the click, so this change preloads 10 rows in one request. ## What Changed - `ui/src/lib/issue-chat-bootstrap.ts`: one module decides when to send a bootstrap and lets page reads share it (`readFromIssueChatBootstrap`). Seeding uses the existing `issueChatBootstrapCache` (replace-style reads are refreshed when newer, merge-style reads are filled only when empty, other companies are ignored). It skips tasks that are loading, loaded in the last 30 seconds, or already fresh in cache, under the id or the identifier. It never aborts a request in flight, so a click that unmounts the list still uses the answer. Each request is bound to the account that sent it; a request sent before the first session read is bound to the first account the session reports, and a sign-out revokes it. A page read shares a bootstrap only while its key holds no data (a refresh after a write always reads the server), and waits at most 5 seconds for it. Every ref of a task (id and identifier) is tracked at request start. Entries expire after 30 seconds. - Task lists preload the chat of their top 10 rows in one request, after the list's own reads settle and the browser is idle (`usePreloadTaskChats`, `when-page-settles.ts`): the Coordinator task list (`TaskList` with `preloadCompanyId`), the streamlined and legacy issues lists and the streamlined Inbox. Recents now uses the same path, so a task in both places loads once. A list that stays open preloads each task at most once in five minutes: live events (#20563's lanes) keep a preloaded task's cache current or mark it stale, and a stale key is read from the server, never from an older bootstrap. All seeded keys are per-task keys on the fast live lane, not the ambient lane. - Open intent (press, click, resting hover or focus) on a task with no chat history in cache warms it with one bootstrap request instead of six separate reads (`prefetchIssueDetailForNavigation`). A task seen before still refreshes its cached reads one by one. - A cold open starts one bootstrap. The gating reads (recent comments, activity, runs, input facts, interactions, attachments, live runs, active run) wait for it instead of sending their own requests. The core issue read stays independent. - A hard load of a task URL starts `GET /api/issues/:ref/chat-bootstrap` from the inline preload script, in parallel with the JavaScript bundle. The page adopts that answer. - Sidebar project task links (Coordinator sidebar) get the same hover, press and focus warming as Recents and `IssueLinkQuicklook`. - Tests: unit tests for the coordination module, Recents, `TaskList` preload, sidebar warming and the cold-intent path. A new layout journey checks that a task the Inbox preloaded reveals once at its latest message without reading its chat again. Journeys that hold one separate read now also hold or fail the bootstrap. The Inbox reveal journey held a read that the chat no longer sends on open (it was flaky on master too), so it now holds the recent comment window. - The perf harness gains `PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS` and the README documents it. ## Verification Measurement setup: production UI builds of master (`e01fe7f84a` and the earlier base `8d2bf78323`) and of this branch, served by one local server with `PAPERCLIP_UI_DEV_MIDDLEWARE=false` and embedded PostgreSQL. Only `index.html` changed between variants (both asset sets were present), so the server, database and seeded task were the same. Four rounds of the issue-detail harness, with master and branch alternated in each round: 20 pairs per S1 cell, 40 per S2 cell. The host was shared with other agents (load average 16 to 43), so the spread is large. Content paint (the task is readable), median paired delta, branch minus master: | Scenario | Profile | Pairs | Content paint | Branch faster in | API requests before content (master / branch) | |---|---|---:|---:|---:|---:| | S1 preloaded click (dwell 1500 ms) | unthrottled | 20 | -45 ms | 15/20 | 25 / 18 | | S1 preloaded click (dwell 1500 ms) | Fast 4G, 4x CPU | 20 | -23 ms | 13/20 | 26 / 18 | | S1 click, no dwell | unthrottled | 20 | -51 ms | 15/20 | 25 / 18 | | S1 click, no dwell | Fast 4G, 4x CPU | 20 | -21 ms | 12/20 | 26 / 18 | | S2 cold deep link | unthrottled | 40 | -61 ms | 28/40 | 54 / 40 | | S2 cold deep link | Fast 4G, 4x CPU | 40 | -129 ms | 29/40 | 47 / 39 | Medians (p25 to p75), content paint in ms: S1 dwell unthrottled 295 (283-315) → 248 (217-276). S1 dwell Fast 4G 1133 (1114-1226) → 1093 (853-1176). S1 no dwell unthrottled 295 (277-328) → 260 (233-332). S1 no dwell Fast 4G 984 (842-1196) → 923 (816-1110). S2 unthrottled 1002 (677-1235) → 896 (619-1280). S2 Fast 4G 4643 (4155-5061) → 4516 (4298-4894). Header paint is neutral to slightly later in two cells: S1 dwell unthrottled +39 ms (3/20 faster) and S2 Fast 4G +56 ms (17/40). In those cells the header now paints in the same frame as the content. In the first two rounds, the median gap from header to content fell from 105-157 ms to 29-74 ms unthrottled and from 557 ms to 298 ms for S2 on Fast 4G. Other header cells are -27 to +1 ms. The local server answers each read in a few milliseconds, so the network wait that the preload removes is small here. The request count is the stable result: a cold open sends one chat request instead of about seven. List page: the preload request starts after the first task row is in the DOM (64 ms after it unthrottled, 200-280 ms after it on Fast 4G, page clock). Time to the first visible row, 30-task list: unthrottled master 1863 ms vs branch 1563 ms (6 pairs, branch faster in 5). Fast 4G master 3626 ms vs branch 3727 ms (14 pairs, branch faster in 6, mixed signs). The preload starts after the row paints, so it cannot delay it; the Fast 4G difference is noise. One 10-task bootstrap took 78-162 ms on the server. Checks run: - `pnpm exec vitest run` for the bootstrap, cache, prefetch, Recents, sidebar, quicklook, `TaskList`, issues list, Inbox, issue detail and Coordinator tests: 18 files, 646 tests pass. - `pnpm exec tsc -p ui/tsconfig.json --noEmit`: clean, except missing generated Storybook catalog files that this change does not touch. - `pnpm check:token-gates`: clean. - `tests/e2e/task-loading-frame.spec.ts`, `disconnect-resilience.spec.ts` and `connection-reviews.spec.ts` (production build): 21 passed, 1 skipped. - `tests/perf/issue-detail/layout-stability.spec.ts` and `plan-startup.spec.ts` against the production build: all pass. The Inbox reveal journey and the new preloaded journey passed 4 of 4 repeats each. - `pnpm verify` asks for broader CI coverage because an e2e spec changed. ## Risks - More server work for list views: one bootstrap of up to 10 tasks per list visit (about fifty queries per task), skipped for tasks already loaded in the last 30 seconds. It runs only after the page's own reads settle and the browser is idle, and only while the tab is visible. - A resting hover on a never-opened task now sends one bootstrap instead of about six reads. The server work per task is about the same; the request count is lower. - If a bootstrap fails, each page read falls back to its own request. The fallback is slower than before for that one open (the reads start when the page mounts, not at the press). - The header can paint a few tens of milliseconds later in the same frame as the content when everything is cached. - An answer that arrives after a sign-out or account change is dropped. An answer for another company is ignored. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, Playwright runs). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2637 lines
117 KiB
TypeScript
2637 lines
117 KiB
TypeScript
import { QueryErrorState } from "./QueryView";
|
|
import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort";
|
|
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
|
|
import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode";
|
|
import { topRenderedNavEntryRefs, usePreloadTaskChats } from "../hooks/usePreloadTaskChats";
|
|
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 { describeError } from "../api/errors";
|
|
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,
|
|
issueCreationRequestFilters,
|
|
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 { 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, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
|
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, "monitoring"] as const;
|
|
const issueStatusLabels: Record<IssueDisplayStatus, string> = {
|
|
backlog: "Backlog",
|
|
todo: "Todo",
|
|
in_progress: "In progress",
|
|
monitoring: "Monitoring",
|
|
in_review: "In review",
|
|
done: "Done",
|
|
blocked: "Blocked",
|
|
cancelled: "Cancelled",
|
|
};
|
|
const progressSegmentClasses: Record<IssueDisplayStatus, string> = {
|
|
backlog: "bg-muted-foreground/40",
|
|
todo: "bg-blue-500",
|
|
in_progress: "bg-yellow-500",
|
|
monitoring: "bg-(--status-task-monitoring)",
|
|
in_review: "bg-violet-500",
|
|
done: "bg-green-500",
|
|
blocked: "bg-red-500",
|
|
cancelled: "bg-neutral-400",
|
|
};
|
|
|
|
/* ── View state ── */
|
|
|
|
export type { IssueSortField } from "../lib/issue-sort";
|
|
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: ISSUE_SORT_FIELDS.includes(parsed.sortField as IssueSortField)
|
|
? 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 : []),
|
|
});
|
|
}
|
|
|
|
|
|
// 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[];
|
|
/** Preload the chat of the top visible rows. Only the top-level tasks page sets this. */
|
|
preloadTaskChats?: boolean;
|
|
isLoading?: boolean;
|
|
error?: Error | null;
|
|
onRetry?: () => void;
|
|
retrying?: boolean;
|
|
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[];
|
|
routineTasksOnly?: boolean;
|
|
onRoutineTasksOnlyChange?: (checked: boolean) => void;
|
|
/** URL sorting overrides the saved collection preference when supplied. */
|
|
sort?: IssueSort;
|
|
onSortChange?: (sort: IssueSort) => void;
|
|
initialSearch?: string;
|
|
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions" | "sortField" | "sortDir">;
|
|
searchWithinLoadedIssues?: boolean;
|
|
baseCreateIssueDefaults?: Record<string, unknown>;
|
|
createIssueLabel?: string;
|
|
defaultSortField?: IssueSortField;
|
|
showChecklist?: boolean;
|
|
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_DISPLAY_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,
|
|
preloadTaskChats = false,
|
|
isLoading,
|
|
error,
|
|
onRetry,
|
|
retrying,
|
|
agents,
|
|
projects,
|
|
liveIssueIds,
|
|
projectId,
|
|
viewStateKey,
|
|
issueLinkState,
|
|
initialAssignees,
|
|
initialWorkspaces,
|
|
initialStatuses,
|
|
routineTasksOnly,
|
|
onRoutineTasksOnlyChange,
|
|
sort,
|
|
onSortChange,
|
|
initialSearch,
|
|
searchFilters,
|
|
searchWithinLoadedIssues = false,
|
|
baseCreateIssueDefaults,
|
|
createIssueLabel,
|
|
defaultSortField,
|
|
showChecklist = defaultSortField === "workflow",
|
|
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(),
|
|
});
|
|
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 [savedViewState, setViewState] = useState<IssueViewState>(() =>
|
|
getInitialWorkspaceViewState(
|
|
initialPreferences,
|
|
initialAssignees,
|
|
initialWorkspaces,
|
|
defaultSortField,
|
|
initialStatuses,
|
|
),
|
|
);
|
|
// The Tasks URL owns this filter; other collections keep their saved state.
|
|
const viewState = useMemo(() => ({
|
|
...savedViewState,
|
|
...sort,
|
|
...(routineTasksOnly === undefined ? {} : { routineTasksOnly }),
|
|
}), [savedViewState, sort, routineTasksOnly]);
|
|
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>) => {
|
|
if (patch.routineTasksOnly !== undefined) onRoutineTasksOnlyChange?.(patch.routineTasksOnly);
|
|
if (patch.sortField !== undefined || patch.sortDir !== undefined) {
|
|
onSortChange?.({
|
|
sortField: patch.sortField ?? viewState.sortField,
|
|
sortDir: patch.sortDir ?? viewState.sortDir,
|
|
});
|
|
}
|
|
setViewState((prev) => {
|
|
const next = { ...prev, ...sort, ...patch };
|
|
saveTaskCollectionPreferences(preferenceLocation, {
|
|
viewState: next,
|
|
columns: visibleIssueColumns,
|
|
});
|
|
return next;
|
|
});
|
|
}, [
|
|
preferenceLocation.companyId,
|
|
preferenceLocation.collectionKey,
|
|
preferenceLocation.legacyColumnsStorageKey,
|
|
preferenceLocation.legacyViewStorageKey,
|
|
visibleIssueColumns,
|
|
onRoutineTasksOnlyChange,
|
|
onSortChange,
|
|
sort,
|
|
viewState.sortField,
|
|
viewState.sortDir,
|
|
]);
|
|
|
|
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 creationRequestFilters = issueCreationRequestFilters(viewState, enableAgentCreatedVisibilityFilter);
|
|
const { data: searchedIssues = [] } = useQuery({
|
|
queryKey: [
|
|
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
|
searchFilters ?? {},
|
|
creationRequestFilters,
|
|
enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : "compact",
|
|
ISSUE_SEARCH_RESULT_LIMIT,
|
|
],
|
|
queryFn: ({ signal }) =>
|
|
issuesApi.listCompact(selectedCompanyId!, {
|
|
projection: enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : undefined,
|
|
q: normalizedIssueSearch,
|
|
projectId,
|
|
limit: ISSUE_SEARCH_RESULT_LIMIT,
|
|
...searchFilters,
|
|
...creationRequestFilters,
|
|
}, { 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 ?? {},
|
|
creationRequestFilters,
|
|
"compact",
|
|
ISSUE_BOARD_COLUMN_RESULT_LIMIT,
|
|
],
|
|
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
|
issuesApi.listCompact(selectedCompanyId!, {
|
|
...searchFilters,
|
|
...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
|
|
projectId,
|
|
displayStatus: status,
|
|
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
|
|
...creationRequestFilters,
|
|
}, { 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,
|
|
);
|
|
const byId = new Map(searchScopedIssues.map(issue => [issue.id, issue]));
|
|
return sortIssues(filteredByControls, viewState, issue => {
|
|
const parent = issue.parentId ? byId.get(issue.parentId) : null;
|
|
return {
|
|
createdBy: issue.createdByAgentId ? agentName(issue.createdByAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.createdByUserId ?? "") ?? "Board",
|
|
assignee: issue.assigneeAgentId ? agentName(issue.assigneeAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.assigneeUserId ?? "") ?? null,
|
|
project: projectById.get(issue.projectId ?? "")?.name,
|
|
parentTask: parent ? `${parent.identifier} ${parent.title}` : null,
|
|
};
|
|
});
|
|
}, [
|
|
searchScopedIssues,
|
|
viewState,
|
|
currentUserId,
|
|
enableAgentCreatedVisibilityFilter,
|
|
liveIssueIds,
|
|
issueFilterContext,
|
|
agentName,
|
|
companyUserLabelMap,
|
|
projectById,
|
|
]);
|
|
|
|
const progressSummary = useMemo(
|
|
() => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length)
|
|
? buildSubIssueProgressSummary(issues)
|
|
: null,
|
|
[issues, showProgressSummary],
|
|
);
|
|
const checklistAffordanceEnabled = useMemo(
|
|
() =>
|
|
showChecklist
|
|
&& viewState.groupBy === "none",
|
|
[showChecklist, 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.routineTasksOnly && 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));
|
|
useWarmTaskDetailCode(!collectionLoading && !error && filtered.length > 0);
|
|
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.displayStatus ?? 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.routineTasksOnly && viewState.nestingEnabled && viewState.viewMode === "list"
|
|
? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues])
|
|
: group.items,
|
|
})), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.routineTasksOnly, 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,
|
|
]);
|
|
// The top rows the list renders, in display order (collapsed groups and
|
|
// parents hide theirs), so the preload warms what a person can click.
|
|
const preloadTaskRefs = useMemo(
|
|
() => preloadTaskChats ? topRenderedNavEntryRefs(flatNavEntries, renderedIssueRowLimit) : [],
|
|
[flatNavEntries, preloadTaskChats, renderedIssueRowLimit],
|
|
);
|
|
usePreloadTaskChats(selectedCompanyId, preloadTaskRefs, !collectionLoading && !error && preloadTaskRefs.length > 0);
|
|
|
|
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, history: true });
|
|
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 === "monitoring" ? "todo" : 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
|
|
enableRoutineTasksFilter={routineTasksOnly !== undefined}
|
|
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 ? <QueryErrorState error={error} action="load tasks" onRetry={onRetry} retrying={retrying} /> : null}
|
|
{externalObjectFilterError && (
|
|
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
|
<p>Unable to filter tasks by external status: {describeError(externalObjectFilterError).body}</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 ? <div role="alert" className="flex items-center gap-2 text-sm text-destructive"><p>{describeError(hierarchy.error, { action: "load task hierarchy" }).body}</p><Button variant="outline" size="sm" onClick={hierarchy.refetch}>Retry hierarchy</Button></div> : null}
|
|
{!collectionLoading && !error && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && (
|
|
<EmptyState
|
|
icon={CircleDot}
|
|
message="No tasks match the current filters or search."
|
|
action={createActionLabel}
|
|
onAction={() => openCreateIssueDialog()}
|
|
/>
|
|
)}
|
|
|
|
{!collectionLoading && !error && !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={group.key === "monitoring" ? undefined : (
|
|
<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 {...taskStatusIconProps(issue)} size="md" 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 {...taskStatusIconProps(issue)} size="md" 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 {...taskStatusIconProps(issue)} size="md" 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>
|
|
);
|
|
}
|