3b9f36403e
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The web UI renders work items in a task/issue list and board columns, each row leading with a status circle next to the title text > - The status-circle wrapper `<span>` was not enforcing cross-axis centering, so the circle aligned to the top of taller rows instead of the title > - This looked broken: the circle floated above the text rather than sitting centered against it, across statuses > - This pull request adds flex centering (`inline-flex` + `items-center`) to the status-icon wrapper spans so the circle is vertically centered with the title in every row > - The benefit is a consistent, correctly aligned task list regardless of task status or row height ## Linked Issues or Issue Description No public GitHub issue exists; describing inline following the bug report template. ### What happened? In the task/issue list and board, the leading status circle was not vertically centered with the task title text. On taller rows the circle sat near the top instead of aligned with the title, so the list looked misaligned across all statuses. ### Expected behavior The status circle should be vertically centered with the title string in every task row, regardless of status. ### Steps to reproduce 1. Open the task list (or board) view in the web UI. 2. Observe rows in different statuses, especially taller rows. 3. Notice the status circle is top-aligned rather than vertically centered with the title text. ### Paperclip version or commit `master` @ 411a9a51 (branch base for this PR). ### Deployment mode Local dev (pnpm dev). ## What Changed - `ui/src/components/IssueColumns.tsx`: added `items-center` to the leading status-icon wrapper span (`inline-flex` container) in `InboxIssueMetaLeading`. - `ui/src/components/IssuesList.tsx`: added `inline-flex items-center` to the two status-icon wrapper spans so the circle centers with the row text (matching the `IssueColumns.tsx` change). ## Verification - Manual: open the task list and board views; confirm the status circle is vertically centered with the title across statuses and on taller rows. - Before/after matches the screenshots from the originating request (broken: circle top-aligned; fixed: circle centered). - `cd ui && npx tsc --noEmit` — type check passes for the touched components. ## Risks - Low risk. CSS-only change to wrapper spans; no logic, data, or API changes. Limited to status-icon alignment in the task list/board rows. ## Model Used - Claude Opus 4.6 (`claude-opus-4-6`), extended thinking + tool use, via Claude Code. ## 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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [ ] I have added or updated tests where applicable (CSS-only alignment change; no meaningful unit test — retitled `refactor:` per contribution gate) - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes (none required) - [x] I have considered and documented any risks above - [x] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1980 lines
82 KiB
TypeScript
1980 lines
82 KiB
TypeScript
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
import { accessApi } from "../api/access";
|
|
import { useDialogActions } from "../context/DialogContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { Link } 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 {
|
|
shouldBlurPageSearchOnEnter,
|
|
shouldBlurPageSearchOnEscape,
|
|
} from "../lib/keyboardShortcuts";
|
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
|
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
|
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
|
import {
|
|
buildSubIssueProgressSummary,
|
|
shouldRenderSubIssueProgressSummary,
|
|
type SubIssueProgressSummary,
|
|
} from "../lib/issue-detail-subissues";
|
|
import { groupBy } from "../lib/groupBy";
|
|
import {
|
|
applyIssueFilters,
|
|
countActiveIssueFilters,
|
|
defaultIssueFilterState,
|
|
issueFilterLabel,
|
|
issuePriorityOrder,
|
|
normalizeIssueFilterState,
|
|
resolveIssueFilterWorkspaceId,
|
|
shouldIncludeIssueFilterWorkspaceOption,
|
|
issueStatusOrder,
|
|
type IssueFilterState,
|
|
} from "../lib/issue-filters";
|
|
import {
|
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
|
getAvailableInboxIssueColumns,
|
|
normalizeInboxIssueColumns,
|
|
resolveIssueWorkspaceName,
|
|
type InboxIssueColumn,
|
|
} from "../lib/inbox";
|
|
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
|
|
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
|
|
import {
|
|
InboxIssueMetaLeading,
|
|
InboxIssueTrailingColumns,
|
|
IssueColumnPicker,
|
|
issueActivityText,
|
|
issueTrailingColumns,
|
|
} from "./IssueColumns";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { EmptyState } from "./EmptyState";
|
|
import { Identity } from "./Identity";
|
|
import { IssueGroupHeader } from "./IssueGroupHeader";
|
|
import { IssueFiltersPopover } from "./IssueFiltersPopover";
|
|
import { IssueRow } from "./IssueRow";
|
|
import { PageSkeleton } from "./PageSkeleton";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
|
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
|
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2, ChevronsDownUp, PanelTopClose, RotateCcw, ListCollapse } 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 { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
|
import { statusBadge } from "../lib/status-colors";
|
|
import { workflowSort } from "../lib/workflow-sort";
|
|
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
|
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
|
|
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
|
|
const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
|
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
|
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
|
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
|
const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320;
|
|
|
|
function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | null {
|
|
if (!element || typeof window === "undefined") return null;
|
|
let current = element.parentElement;
|
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
const overflowY = window.getComputedStyle(current).overflowY;
|
|
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
|
|
return current;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
const boardIssueStatuses = ISSUE_STATUSES;
|
|
const issueStatusLabels: Record<IssueStatus, string> = {
|
|
backlog: "Backlog",
|
|
todo: "Todo",
|
|
in_progress: "In progress",
|
|
in_review: "In review",
|
|
done: "Done",
|
|
blocked: "Blocked",
|
|
cancelled: "Cancelled",
|
|
};
|
|
const progressSegmentClasses: Record<IssueStatus, string> = {
|
|
backlog: "bg-muted-foreground/40",
|
|
todo: "bg-blue-500",
|
|
in_progress: "bg-yellow-500",
|
|
in_review: "bg-violet-500",
|
|
done: "bg-green-500",
|
|
blocked: "bg-red-500",
|
|
cancelled: "bg-neutral-400",
|
|
};
|
|
|
|
/* ── View state ── */
|
|
|
|
export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow";
|
|
export type BoardCardDensity = "auto" | "compact" | "comfortable";
|
|
export type BoardColdLaneMode = "auto" | "collapsed" | "expanded";
|
|
export type BoardColumnPageSize = KanbanColumnPageSize;
|
|
|
|
export type IssueViewState = IssueFilterState & {
|
|
sortField: IssueSortField;
|
|
sortDir: "asc" | "desc";
|
|
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
|
|
viewMode: "list" | "board";
|
|
nestingEnabled: boolean;
|
|
collapsedGroups: string[];
|
|
collapsedParents: string[];
|
|
boardCardDensity: BoardCardDensity;
|
|
boardColdLaneMode: BoardColdLaneMode;
|
|
boardColumnPageSize: BoardColumnPageSize;
|
|
};
|
|
|
|
const defaultViewState: IssueViewState = {
|
|
...defaultIssueFilterState,
|
|
sortField: "updated",
|
|
sortDir: "desc",
|
|
groupBy: "none",
|
|
viewMode: "list",
|
|
nestingEnabled: true,
|
|
collapsedGroups: [],
|
|
collapsedParents: [],
|
|
boardCardDensity: "auto",
|
|
boardColdLaneMode: "auto",
|
|
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
|
|
};
|
|
|
|
function normalizeBoardCardDensity(value: unknown): BoardCardDensity {
|
|
return value === "compact" || value === "comfortable" || value === "auto" ? value : "auto";
|
|
}
|
|
|
|
function normalizeBoardColdLaneMode(value: unknown): BoardColdLaneMode {
|
|
return value === "collapsed" || value === "expanded" || value === "auto" ? value : "auto";
|
|
}
|
|
|
|
function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize {
|
|
return KANBAN_COLUMN_PAGE_SIZE_OPTIONS.includes(value as BoardColumnPageSize)
|
|
? value as BoardColumnPageSize
|
|
: KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
|
}
|
|
|
|
function getViewState(key: string): IssueViewState {
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
return {
|
|
...defaultViewState,
|
|
...parsed,
|
|
...normalizeIssueFilterState(parsed),
|
|
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
|
|
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
|
|
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
|
|
};
|
|
}
|
|
} catch { /* ignore */ }
|
|
return { ...defaultViewState };
|
|
}
|
|
|
|
function saveViewState(key: string, state: IssueViewState) {
|
|
localStorage.setItem(key, JSON.stringify(state));
|
|
}
|
|
|
|
function getInitialViewState(
|
|
key: string,
|
|
initialAssignees?: string[],
|
|
defaultSortField?: IssueSortField,
|
|
): IssueViewState {
|
|
const hasStored = hasStoredViewState(key);
|
|
const stored = getViewState(key);
|
|
const base = !hasStored && defaultSortField
|
|
? { ...stored, sortField: defaultSortField, sortDir: "asc" as const }
|
|
: stored;
|
|
if (!initialAssignees) return base;
|
|
return {
|
|
...base,
|
|
assignees: initialAssignees,
|
|
statuses: [],
|
|
};
|
|
}
|
|
|
|
function getInitialWorkspaceViewState(
|
|
key: string,
|
|
initialAssignees?: string[],
|
|
initialWorkspaces?: string[],
|
|
defaultSortField?: IssueSortField,
|
|
): IssueViewState {
|
|
const stored = getInitialViewState(key, initialAssignees, defaultSortField);
|
|
if (!initialWorkspaces) return stored;
|
|
return {
|
|
...stored,
|
|
workspaces: initialWorkspaces,
|
|
statuses: [],
|
|
};
|
|
}
|
|
|
|
function hasStoredViewState(key: string): boolean {
|
|
try {
|
|
return localStorage.getItem(key) !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getIssueColumnsStorageKey(key: string): string {
|
|
return `${key}:issue-columns`;
|
|
}
|
|
|
|
function loadIssueColumns(key: string): InboxIssueColumn[] {
|
|
try {
|
|
const raw = localStorage.getItem(getIssueColumnsStorageKey(key));
|
|
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
|
return normalizeInboxIssueColumns(parsed);
|
|
} catch {
|
|
return DEFAULT_INBOX_ISSUE_COLUMNS;
|
|
}
|
|
}
|
|
|
|
function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
|
|
try {
|
|
localStorage.setItem(
|
|
getIssueColumnsStorageKey(key),
|
|
JSON.stringify(normalizeInboxIssueColumns(columns)),
|
|
);
|
|
} catch {
|
|
// Ignore localStorage failures.
|
|
}
|
|
}
|
|
|
|
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
|
if (state.sortField === "workflow") {
|
|
const ordered = workflowSort(issues);
|
|
return state.sortDir === "desc" ? [...ordered].reverse() : ordered;
|
|
}
|
|
const sorted = [...issues];
|
|
const dir = state.sortDir === "asc" ? 1 : -1;
|
|
sorted.sort((a, b) => {
|
|
switch (state.sortField) {
|
|
case "status":
|
|
return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
|
|
case "priority":
|
|
return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
|
|
case "title":
|
|
return dir * a.title.localeCompare(b.title);
|
|
case "created":
|
|
return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
case "updated":
|
|
return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean {
|
|
if (!normalizedSearch) return true;
|
|
return [
|
|
issue.identifier,
|
|
issue.title,
|
|
issue.description,
|
|
].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
|
}
|
|
|
|
function isActionableWorkflowStatus(status: IssueStatus): boolean {
|
|
return status !== "done" && status !== "cancelled" && status !== "blocked";
|
|
}
|
|
|
|
function buildChecklistStepNumberMap(issues: Issue[], nestingEnabled: boolean): Map<string, string> {
|
|
const stepNumberByIssueId = new Map<string, string>();
|
|
|
|
if (!nestingEnabled) {
|
|
issues.forEach((issue, index) => {
|
|
stepNumberByIssueId.set(issue.id, String(index + 1));
|
|
});
|
|
return stepNumberByIssueId;
|
|
}
|
|
|
|
const { roots, childMap } = buildIssueTree(issues);
|
|
const visit = (siblings: Issue[], prefix: string | null) => {
|
|
siblings.forEach((issue, index) => {
|
|
const stepNumber = prefix ? `${prefix}.${index + 1}` : String(index + 1);
|
|
stepNumberByIssueId.set(issue.id, stepNumber);
|
|
visit(childMap.get(issue.id) ?? [], stepNumber);
|
|
});
|
|
};
|
|
visit(roots, null);
|
|
|
|
issues.forEach((issue, index) => {
|
|
if (!stepNumberByIssueId.has(issue.id)) {
|
|
stepNumberByIssueId.set(issue.id, String(index + 1));
|
|
}
|
|
});
|
|
|
|
return stepNumberByIssueId;
|
|
}
|
|
|
|
function buildPreviousSiblingIssueIdMap(issues: Issue[], nestingEnabled: boolean): Map<string, string> {
|
|
const previousSiblingByIssueId = new Map<string, string>();
|
|
|
|
if (!nestingEnabled) {
|
|
const previousByParentId = new Map<string, Issue>();
|
|
for (const issue of issues) {
|
|
if (!issue.parentId) continue;
|
|
const previousSibling = previousByParentId.get(issue.parentId);
|
|
if (previousSibling) {
|
|
previousSiblingByIssueId.set(issue.id, previousSibling.id);
|
|
}
|
|
previousByParentId.set(issue.parentId, issue);
|
|
}
|
|
return previousSiblingByIssueId;
|
|
}
|
|
|
|
const { roots, childMap } = buildIssueTree(issues);
|
|
const visit = (siblings: Issue[]) => {
|
|
siblings.forEach((issue, index) => {
|
|
const previousSibling = index > 0 ? siblings[index - 1] : null;
|
|
if (issue.parentId && previousSibling?.parentId === issue.parentId) {
|
|
previousSiblingByIssueId.set(issue.id, previousSibling.id);
|
|
}
|
|
visit(childMap.get(issue.id) ?? []);
|
|
});
|
|
};
|
|
visit(roots);
|
|
|
|
return previousSiblingByIssueId;
|
|
}
|
|
|
|
function shouldSuppressSinglePreviousSiblingBlockerChip(
|
|
issue: Issue,
|
|
unresolvedVisibleBlockerIds: string[],
|
|
previousSiblingIssueId: string | undefined,
|
|
): boolean {
|
|
return Boolean(
|
|
issue.parentId
|
|
&& previousSiblingIssueId
|
|
&& (issue.blockedBy ?? []).length === 1
|
|
&& unresolvedVisibleBlockerIds.length === 1
|
|
&& unresolvedVisibleBlockerIds[0] === previousSiblingIssueId,
|
|
);
|
|
}
|
|
|
|
/* ── Component ── */
|
|
|
|
interface Agent {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
type CreatorOption = {
|
|
id: string;
|
|
label: string;
|
|
kind: "agent" | "user";
|
|
searchText?: string;
|
|
};
|
|
|
|
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
|
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
|
|
|
|
interface IssuesListProps {
|
|
issues: Issue[];
|
|
isLoading?: boolean;
|
|
error?: Error | null;
|
|
agents?: Agent[];
|
|
projects?: ProjectOption[];
|
|
liveIssueIds?: Set<string>;
|
|
projectId?: string;
|
|
viewStateKey: string;
|
|
issueLinkState?: unknown;
|
|
initialAssignees?: string[];
|
|
initialWorkspaces?: string[];
|
|
initialSearch?: string;
|
|
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
|
searchWithinLoadedIssues?: boolean;
|
|
baseCreateIssueDefaults?: Record<string, unknown>;
|
|
createIssueLabel?: string;
|
|
defaultSortField?: IssueSortField;
|
|
showProgressSummary?: boolean;
|
|
/**
|
|
* When set together with `showProgressSummary`, the progress strip fetches
|
|
* the recursive cost-summary for this parent issue and renders aggregate
|
|
* tokens + wall-clock runtime for every run in the tree.
|
|
*/
|
|
parentIssueIdForCostSummary?: string;
|
|
enableRoutineVisibilityFilter?: boolean;
|
|
hasMoreIssues?: boolean;
|
|
isLoadingMoreIssues?: boolean;
|
|
mutedIssueIds?: Set<string>;
|
|
issueBadgeById?: Map<string, string>;
|
|
onLoadMoreIssues?: () => void;
|
|
onSearchChange?: (search: string) => void;
|
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
|
}
|
|
|
|
function IssueSearchInput({
|
|
value,
|
|
onDebouncedChange,
|
|
}: {
|
|
value: string;
|
|
onDebouncedChange?: (search: string) => void;
|
|
}) {
|
|
const [draftValue, setDraftValue] = useState(value);
|
|
const lastCommittedValueRef = useRef(value);
|
|
|
|
useEffect(() => {
|
|
setDraftValue(value);
|
|
lastCommittedValueRef.current = value;
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return;
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
lastCommittedValueRef.current = draftValue;
|
|
startTransition(() => {
|
|
onDebouncedChange(draftValue);
|
|
});
|
|
}, ISSUE_SEARCH_DEBOUNCE_MS);
|
|
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [draftValue, onDebouncedChange]);
|
|
|
|
return (
|
|
<div className="relative w-48 sm:w-64 md:w-80">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={draftValue}
|
|
onChange={(e) => {
|
|
setDraftValue(e.target.value);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (shouldBlurPageSearchOnEnter({
|
|
key: e.key,
|
|
isComposing: e.nativeEvent.isComposing,
|
|
})) {
|
|
e.currentTarget.blur();
|
|
return;
|
|
}
|
|
|
|
if (shouldBlurPageSearchOnEscape({
|
|
key: e.key,
|
|
isComposing: e.nativeEvent.isComposing,
|
|
currentValue: e.currentTarget.value,
|
|
})) {
|
|
e.currentTarget.blur();
|
|
}
|
|
}}
|
|
placeholder="Search tasks..."
|
|
className="pl-7 text-xs sm:text-sm"
|
|
aria-label="Search tasks"
|
|
data-page-search-target="true"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubIssueProgressSummaryStrip({
|
|
summary,
|
|
issueLinkState,
|
|
parentIssueIdForCostSummary,
|
|
}: {
|
|
summary: SubIssueProgressSummary;
|
|
issueLinkState?: unknown;
|
|
parentIssueIdForCostSummary?: string;
|
|
}) {
|
|
const target = summary.target;
|
|
const targetIssue = target?.issue ?? null;
|
|
const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? "";
|
|
const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined;
|
|
const statusEntries = ISSUE_STATUSES
|
|
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
|
|
.filter((entry) => entry.count > 0);
|
|
|
|
// Refresh fast enough that the runtime ticks up while a sub-issue is still
|
|
// running, but slow enough not to hammer the recursive CTE on idle trees.
|
|
const hasInProgress = summary.inProgressCount > 0;
|
|
const { data: costSummary } = useQuery({
|
|
queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }),
|
|
queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }),
|
|
enabled: !!parentIssueIdForCostSummary,
|
|
refetchInterval: hasInProgress ? 5_000 : 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>
|
|
);
|
|
}
|
|
|
|
export function IssuesList({
|
|
issues,
|
|
isLoading,
|
|
error,
|
|
agents,
|
|
projects,
|
|
liveIssueIds,
|
|
projectId,
|
|
viewStateKey,
|
|
issueLinkState,
|
|
initialAssignees,
|
|
initialWorkspaces,
|
|
initialSearch,
|
|
searchFilters,
|
|
searchWithinLoadedIssues = false,
|
|
baseCreateIssueDefaults,
|
|
createIssueLabel,
|
|
defaultSortField,
|
|
showProgressSummary = false,
|
|
parentIssueIdForCostSummary,
|
|
enableRoutineVisibilityFilter = false,
|
|
hasMoreIssues = false,
|
|
isLoadingMoreIssues = false,
|
|
mutedIssueIds,
|
|
issueBadgeById,
|
|
onLoadMoreIssues,
|
|
onSearchChange,
|
|
onUpdateIssue,
|
|
}: IssuesListProps) {
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewIssue } = useDialogActions();
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const { data: companyMembers } = useQuery({
|
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
retry: false,
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
|
|
|
// Scope the storage key per company so folding/view state is independent across companies.
|
|
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
|
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
|
|
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
|
|
|
|
const [viewState, setViewState] = useState<IssueViewState>(() =>
|
|
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
|
|
);
|
|
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 [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
|
const renderedIssueIdsRef = useRef("");
|
|
const initialServerFillRequestedRef = useRef(false);
|
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
|
|
|
useEffect(() => {
|
|
setIssueSearch(initialSearch ?? "");
|
|
}, [initialSearch]);
|
|
|
|
// Reload view state whenever the persisted context changes.
|
|
const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`);
|
|
useEffect(() => {
|
|
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`;
|
|
if (prevViewStateContextKey.current !== nextContextKey) {
|
|
prevViewStateContextKey.current = nextContextKey;
|
|
setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField));
|
|
}
|
|
}, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey, defaultSortField]);
|
|
|
|
const prevColumnsScopedKey = useRef(scopedKey);
|
|
useEffect(() => {
|
|
if (prevColumnsScopedKey.current !== scopedKey) {
|
|
prevColumnsScopedKey.current = scopedKey;
|
|
setVisibleIssueColumns(loadIssueColumns(scopedKey));
|
|
}
|
|
}, [scopedKey]);
|
|
|
|
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
|
setViewState((prev) => {
|
|
const next = { ...prev, ...patch };
|
|
saveViewState(scopedKey, next);
|
|
return next;
|
|
});
|
|
}, [scopedKey]);
|
|
|
|
// Prune stale IDs from collapsedParents whenever the issue list changes.
|
|
// Deleted or reassigned issues leave orphan IDs in localStorage; this keeps
|
|
// the stored array bounded to only current parent IDs.
|
|
useEffect(() => {
|
|
const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]);
|
|
const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id));
|
|
if (pruned.length !== viewState.collapsedParents.length) {
|
|
updateView({ collapsedParents: pruned });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [issues]);
|
|
|
|
const { data: searchedIssues = [] } = useQuery({
|
|
queryKey: [
|
|
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
|
searchFilters ?? {},
|
|
ISSUE_SEARCH_RESULT_LIMIT,
|
|
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
|
|
],
|
|
queryFn: () =>
|
|
issuesApi.list(selectedCompanyId!, {
|
|
q: normalizedIssueSearch,
|
|
projectId,
|
|
limit: ISSUE_SEARCH_RESULT_LIMIT,
|
|
...searchFilters,
|
|
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
|
|
}),
|
|
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 ?? {},
|
|
ISSUE_BOARD_COLUMN_RESULT_LIMIT,
|
|
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
|
|
],
|
|
queryFn: () =>
|
|
issuesApi.list(selectedCompanyId!, {
|
|
...searchFilters,
|
|
...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
|
|
projectId,
|
|
status,
|
|
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
|
|
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
|
|
}),
|
|
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 workspaceOptions = useMemo(() => {
|
|
const options = new Map<string, string>();
|
|
for (const [workspaceId, workspaceName] of workspaceNameMap) {
|
|
options.set(workspaceId, workspaceName);
|
|
}
|
|
return [...options.entries()]
|
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
|
.map(([id, name]) => ({ id, name }));
|
|
}, [workspaceNameMap]);
|
|
|
|
const creatorOptions = useMemo<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)),
|
|
[availableIssueColumnSet, 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 filtered = useMemo(() => {
|
|
const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues;
|
|
const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
|
|
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
|
|
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
|
|
: sourceIssues;
|
|
const filteredByControls = applyIssueFilters(
|
|
searchScopedIssues,
|
|
viewState,
|
|
currentUserId,
|
|
enableRoutineVisibilityFilter,
|
|
liveIssueIds,
|
|
issueFilterWorkspaceContext,
|
|
);
|
|
return sortIssues(filteredByControls, viewState);
|
|
}, [
|
|
boardIssues,
|
|
issues,
|
|
searchedIssues,
|
|
searchWithinLoadedIssues,
|
|
viewState,
|
|
normalizedIssueSearch,
|
|
currentUserId,
|
|
enableRoutineVisibilityFilter,
|
|
liveIssueIds,
|
|
issueFilterWorkspaceContext,
|
|
]);
|
|
|
|
const progressSummary = useMemo(
|
|
() => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length)
|
|
? buildSubIssueProgressSummary(issues)
|
|
: null,
|
|
[issues, showProgressSummary],
|
|
);
|
|
const checklistAffordanceEnabled = useMemo(
|
|
() =>
|
|
defaultSortField === "workflow"
|
|
&& viewState.groupBy === "none",
|
|
[defaultSortField, viewState.groupBy],
|
|
);
|
|
const workflowChecklistMeta = useMemo(() => {
|
|
if (!checklistAffordanceEnabled) return null;
|
|
|
|
const visibleIssueIds = new Set(filtered.map((issue) => issue.id));
|
|
const stepNumberByIssueId = buildChecklistStepNumberMap(filtered, viewState.nestingEnabled);
|
|
const previousSiblingIssueIdByIssueId = buildPreviousSiblingIssueIdMap(filtered, viewState.nestingEnabled);
|
|
const unresolvedVisibleBlockersByIssueId = new Map<string, string[]>();
|
|
|
|
filtered.forEach((issue) => {
|
|
const unresolvedVisible = (issue.blockedBy ?? [])
|
|
.map((blocker) => blocker.id)
|
|
.filter((blockerId) => {
|
|
if (!visibleIssueIds.has(blockerId)) return false;
|
|
const blockerIssue = issueById.get(blockerId);
|
|
if (!blockerIssue) return false;
|
|
return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled";
|
|
});
|
|
const shouldSuppressChip = shouldSuppressSinglePreviousSiblingBlockerChip(
|
|
issue,
|
|
unresolvedVisible,
|
|
previousSiblingIssueIdByIssueId.get(issue.id),
|
|
);
|
|
unresolvedVisibleBlockersByIssueId.set(issue.id, shouldSuppressChip ? [] : unresolvedVisible);
|
|
});
|
|
|
|
const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null;
|
|
const currentStepIssue = firstActionable ?? filtered.find((issue) => issue.status === "blocked") ?? null;
|
|
|
|
return {
|
|
stepNumberByIssueId,
|
|
unresolvedVisibleBlockersByIssueId,
|
|
currentStepIssueId: currentStepIssue?.id ?? null,
|
|
};
|
|
}, [checklistAffordanceEnabled, filtered, issueById, viewState.nestingEnabled]);
|
|
|
|
const { data: labels } = useQuery({
|
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
|
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
|
|
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
|
|
const boardCompactCards =
|
|
viewState.boardCardDensity === "compact"
|
|
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
|
|
const boardCollapsedStatuses = useMemo(
|
|
() =>
|
|
viewState.boardColdLaneMode === "collapsed"
|
|
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
|
|
? [...KANBAN_COLD_STATUSES]
|
|
: [],
|
|
[boardHighVolume, viewState.boardColdLaneMode],
|
|
);
|
|
const boardDensityCustomized =
|
|
viewState.boardCardDensity !== "auto"
|
|
|| viewState.boardColdLaneMode !== "auto"
|
|
|| viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
|
|
|
const groupedContent = useMemo(() => {
|
|
if (viewState.groupBy === "none") {
|
|
return [{ key: "__all", label: null as string | null, items: filtered }];
|
|
}
|
|
if (viewState.groupBy === "status") {
|
|
const groups = groupBy(filtered, (i) => i.status);
|
|
return issueStatusOrder
|
|
.filter((s) => groups[s]?.length)
|
|
.map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! }));
|
|
}
|
|
if (viewState.groupBy === "priority") {
|
|
const groups = groupBy(filtered, (i) => i.priority);
|
|
return issuePriorityOrder
|
|
.filter((p) => groups[p]?.length)
|
|
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
|
|
}
|
|
if (viewState.groupBy === "workspace") {
|
|
const groups = groupBy(
|
|
filtered,
|
|
(issue) => resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext) ?? "__no_workspace",
|
|
);
|
|
return Object.keys(groups)
|
|
.sort((a, b) => {
|
|
// Groups with items first, "no workspace" last
|
|
if (a === "__no_workspace") return 1;
|
|
if (b === "__no_workspace") return -1;
|
|
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
|
})
|
|
.map((key) => ({
|
|
key,
|
|
label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)),
|
|
items: groups[key]!,
|
|
}));
|
|
}
|
|
if (viewState.groupBy === "project") {
|
|
const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project");
|
|
return Object.keys(groups)
|
|
.sort((a, b) => {
|
|
if (a === "__no_project") return 1;
|
|
if (b === "__no_project") return -1;
|
|
const labelA = projectById.get(a)?.name ?? a;
|
|
const labelB = projectById.get(b)?.name ?? b;
|
|
return labelA.localeCompare(labelB);
|
|
})
|
|
.map((key) => ({
|
|
key,
|
|
label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)),
|
|
items: groups[key]!,
|
|
}));
|
|
}
|
|
if (viewState.groupBy === "parent") {
|
|
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
|
return Object.keys(groups)
|
|
.sort((a, b) => {
|
|
// Groups with items first, "no parent" last
|
|
if (a === "__no_parent") return 1;
|
|
if (b === "__no_parent") return -1;
|
|
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
|
})
|
|
.map((key) => ({
|
|
key,
|
|
label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)),
|
|
items: groups[key]!,
|
|
}));
|
|
}
|
|
// assignee
|
|
const groups = groupBy(
|
|
filtered,
|
|
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
|
|
);
|
|
return Object.keys(groups).map((key) => ({
|
|
key,
|
|
label:
|
|
key === "__unassigned"
|
|
? "Unassigned"
|
|
: key.startsWith("__user:")
|
|
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User")
|
|
: (agentName(key) ?? key.slice(0, 8)),
|
|
items: groups[key]!,
|
|
}));
|
|
}, [
|
|
filtered,
|
|
issueFilterWorkspaceContext,
|
|
viewState.groupBy,
|
|
agents,
|
|
agentName,
|
|
currentUserId,
|
|
workspaceNameMap,
|
|
issueTitleMap,
|
|
companyUserLabelMap,
|
|
projectById,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (viewState.viewMode !== "list") return;
|
|
const nextIssueIds = filtered.map((issue) => issue.id).join("|");
|
|
const previousIssueIds = renderedIssueIdsRef.current;
|
|
renderedIssueIdsRef.current = nextIssueIds;
|
|
|
|
setRenderedIssueRowLimit((current) => {
|
|
const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
|
const listAppended = previousIssueIds.length > 0
|
|
&& nextIssueIds.startsWith(previousIssueIds)
|
|
&& filtered.length >= current;
|
|
if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit));
|
|
return nextInitialLimit;
|
|
});
|
|
}, [filtered, viewState.viewMode]);
|
|
|
|
const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length;
|
|
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
|
const loadMoreIssueRows = useCallback(() => {
|
|
if (viewState.viewMode !== "list") return;
|
|
if (hasMoreRenderedRows) {
|
|
startTransition(() => {
|
|
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
|
|
});
|
|
return;
|
|
}
|
|
if (hasMoreIssues && !isLoadingMoreIssues) {
|
|
onLoadMoreIssues?.();
|
|
}
|
|
}, [
|
|
filtered.length,
|
|
hasMoreIssues,
|
|
hasMoreRenderedRows,
|
|
isLoadingMoreIssues,
|
|
onLoadMoreIssues,
|
|
viewState.viewMode,
|
|
]);
|
|
|
|
const canLoadMoreIssues = viewState.viewMode === "list"
|
|
&& !isLoading
|
|
&& (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues));
|
|
|
|
useEffect(() => {
|
|
if (!canLoadMoreIssues) return;
|
|
let animationFrameId: number | null = null;
|
|
const scrollContainer = findIssuesScrollContainer(rootRef.current);
|
|
const scrollTarget: Window | HTMLElement = scrollContainer ?? window;
|
|
|
|
const checkScrollPosition = (trigger: "initial" | "scroll" | "resize" = "scroll") => {
|
|
if (animationFrameId !== null) return;
|
|
animationFrameId = window.requestAnimationFrame(() => {
|
|
animationFrameId = null;
|
|
const scrollHeight = scrollContainer?.scrollHeight ?? document.documentElement.scrollHeight;
|
|
if (scrollHeight === 0) return;
|
|
const viewportHeight = scrollContainer?.clientHeight ?? window.innerHeight;
|
|
const scrollBottom = scrollContainer
|
|
? scrollContainer.scrollTop + scrollContainer.clientHeight
|
|
: window.scrollY + window.innerHeight;
|
|
const hasScrollableOverflow = scrollHeight > viewportHeight + 1;
|
|
const threshold = scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX;
|
|
if (scrollBottom >= threshold) {
|
|
if (trigger === "initial" && !hasMoreRenderedRows && hasMoreIssues && !hasScrollableOverflow) {
|
|
if (initialServerFillRequestedRef.current) return;
|
|
initialServerFillRequestedRef.current = true;
|
|
}
|
|
loadMoreIssueRows();
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleScroll = () => checkScrollPosition("scroll");
|
|
const handleResize = () => checkScrollPosition("resize");
|
|
scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
|
|
window.addEventListener("resize", handleResize);
|
|
checkScrollPosition("initial");
|
|
|
|
return () => {
|
|
scrollTarget.removeEventListener("scroll", handleScroll);
|
|
window.removeEventListener("resize", handleResize);
|
|
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
|
|
};
|
|
}, [canLoadMoreIssues, hasMoreIssues, hasMoreRenderedRows, loadMoreIssueRows]);
|
|
|
|
const newIssueDefaults = useCallback((group?: { key: string; items: Issue[] }) => {
|
|
const groupKey = group?.key;
|
|
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
|
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
|
if (groupKey) {
|
|
if (viewState.groupBy === "status") defaults.status = groupKey;
|
|
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
|
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
|
|
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
|
else defaults.assigneeAgentId = groupKey;
|
|
}
|
|
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
|
|
else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") {
|
|
const representativeIssue = group?.items.find((issue) =>
|
|
issue.executionWorkspaceId === groupKey || issue.projectWorkspaceId === groupKey,
|
|
) ?? null;
|
|
const executionWorkspace = executionWorkspaceById.get(groupKey);
|
|
if (executionWorkspace) {
|
|
defaults.executionWorkspaceId = groupKey;
|
|
defaults.executionWorkspaceMode = "reuse_existing";
|
|
if (executionWorkspace.projectWorkspaceId) defaults.projectWorkspaceId = executionWorkspace.projectWorkspaceId;
|
|
const groupedProjectId = executionWorkspace.projectId
|
|
?? (executionWorkspace.projectWorkspaceId
|
|
? projectWorkspaceById.get(executionWorkspace.projectWorkspaceId)?.projectId
|
|
: null)
|
|
?? (representativeIssue?.executionWorkspaceId === groupKey ? representativeIssue.projectId : null);
|
|
if (groupedProjectId) defaults.projectId = groupedProjectId;
|
|
} else {
|
|
const projectWorkspace = projectWorkspaceById.get(groupKey);
|
|
if (projectWorkspace) {
|
|
defaults.projectWorkspaceId = groupKey;
|
|
defaults.projectId = projectWorkspace.projectId;
|
|
}
|
|
}
|
|
}
|
|
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
|
const parentIssue = issueById.get(groupKey);
|
|
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
|
else defaults.parentId = groupKey;
|
|
}
|
|
}
|
|
return defaults;
|
|
}, [
|
|
baseCreateIssueDefaults,
|
|
currentUserId,
|
|
executionWorkspaceById,
|
|
issueById,
|
|
projectId,
|
|
projectWorkspaceById,
|
|
viewState.groupBy,
|
|
]);
|
|
|
|
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Task";
|
|
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Task";
|
|
const openCreateIssueDialog = useCallback((group?: { key: string; items: Issue[] }) => {
|
|
openNewIssue(newIssueDefaults(group));
|
|
}, [newIssueDefaults, openNewIssue]);
|
|
|
|
const filterToWorkspace = useCallback((workspaceId: string) => {
|
|
updateView({ workspaces: [workspaceId] });
|
|
}, [updateView]);
|
|
|
|
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
|
const normalized = normalizeInboxIssueColumns(next);
|
|
setVisibleIssueColumns(normalized);
|
|
saveIssueColumns(scopedKey, normalized);
|
|
}, [scopedKey]);
|
|
|
|
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
|
if (enabled) {
|
|
setIssueColumns([...visibleIssueColumns, column]);
|
|
return;
|
|
}
|
|
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
|
}, [setIssueColumns, visibleIssueColumns]);
|
|
|
|
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
|
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
|
setAssigneePickerIssueId(null);
|
|
setAssigneeSearch("");
|
|
}, [onUpdateIssue]);
|
|
|
|
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
|
|
|
return (
|
|
<div ref={rootRef} className="space-y-4">
|
|
{progressSummary ? (
|
|
<SubIssueProgressSummaryStrip
|
|
summary={progressSummary}
|
|
issueLinkState={issueLinkState}
|
|
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Toolbar */}
|
|
<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">
|
|
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
|
|
<Plus className="h-4 w-4 sm:mr-1" />
|
|
<span className="hidden sm:inline">{createButtonLabel}</span>
|
|
</Button>
|
|
<IssueSearchInput
|
|
value={issueSearch}
|
|
onDebouncedChange={(nextSearch) => {
|
|
setIssueSearch(nextSearch);
|
|
onSearchChange?.(nextSearch);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
|
{/* View mode toggle */}
|
|
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1">
|
|
<button
|
|
className={`p-1.5 transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
onClick={() => updateView({ viewMode: "list" })}
|
|
title="List view"
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
onClick={() => updateView({ viewMode: "board" })}
|
|
title="Board view"
|
|
>
|
|
<Columns3 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: "auto",
|
|
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}
|
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
|
title="Choose which task columns stay visible"
|
|
iconOnly
|
|
/>
|
|
|
|
<IssueFiltersPopover
|
|
state={viewState}
|
|
onChange={updateView}
|
|
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}
|
|
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
|
iconOnly
|
|
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
|
|
/>
|
|
|
|
{/* 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">
|
|
{([
|
|
["workflow", "Workflow"],
|
|
["status", "Status"],
|
|
["priority", "Priority"],
|
|
["title", "Title"],
|
|
["created", "Created"],
|
|
["updated", "Updated"],
|
|
] as const).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">
|
|
{([
|
|
["status", "Status"],
|
|
["priority", "Priority"],
|
|
["assignee", "Assignee"],
|
|
["project", "Project"],
|
|
["workspace", "Workspace"],
|
|
["parent", "Parent Task"],
|
|
["none", "None"],
|
|
] as const).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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && <PageSkeleton variant="issues-list" />}
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
{!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>
|
|
)}
|
|
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
|
<EmptyState
|
|
icon={CircleDot}
|
|
message="No tasks match the current filters or search."
|
|
action={createActionLabel}
|
|
onAction={() => openCreateIssueDialog()}
|
|
/>
|
|
)}
|
|
|
|
{viewState.viewMode === "board" ? (
|
|
<KanbanBoard
|
|
issues={filtered}
|
|
agents={agents}
|
|
liveIssueIds={liveIssueIds}
|
|
compactCards={boardCompactCards}
|
|
collapsedStatuses={boardCollapsedStatuses}
|
|
initialVisibleCount={viewState.boardColumnPageSize}
|
|
revealIncrement={viewState.boardColumnPageSize}
|
|
onUpdateIssue={onUpdateIssue}
|
|
/>
|
|
) : (
|
|
<>
|
|
{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 && (
|
|
<IssueGroupHeader
|
|
label={group.label}
|
|
collapsible
|
|
collapsed={viewState.collapsedGroups.includes(group.key)}
|
|
onToggle={() => {
|
|
updateView({
|
|
collapsedGroups: viewState.collapsedGroups.includes(group.key)
|
|
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
|
: [...viewState.collapsedGroups, group.key],
|
|
});
|
|
}}
|
|
trailing={(
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="-mr-2 text-muted-foreground"
|
|
title={`New task in ${group.label}`}
|
|
aria-label={`New task in ${group.label}`}
|
|
onClick={() => openCreateIssueDialog(group)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
/>
|
|
)}
|
|
<CollapsibleContent>
|
|
{(() => {
|
|
const { roots, childMap } = viewState.nestingEnabled
|
|
? buildIssueTree(group.items)
|
|
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
|
|
|
const renderIssueRow = (issue: Issue, depth: number) => {
|
|
if (remainingRowsToRender <= 0) return null;
|
|
remainingRowsToRender -= 1;
|
|
|
|
const children = childMap.get(issue.id) ?? [];
|
|
const hasChildren = children.length > 0;
|
|
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
|
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
|
const useDeferredRowRendering = !(hasChildren && isExpanded);
|
|
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
|
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
|
const issueBadge = issueBadgeById?.get(issue.id);
|
|
const isMutedIssue = mutedIssueIds?.has(issue.id) === true;
|
|
const assigneeUserProfile = issue.assigneeUserId
|
|
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
|
|
: null;
|
|
const assigneeUserLabel = formatAssigneeUserLabel(
|
|
issue.assigneeUserId,
|
|
currentUserId,
|
|
companyUserLabelMap,
|
|
) ?? assigneeUserProfile?.label ?? null;
|
|
const 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-[10px] 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}
|
|
style={{
|
|
...(depth > 0 ? { paddingLeft: `${depth * 16}px` } : {}),
|
|
...(useDeferredRowRendering
|
|
? {
|
|
contentVisibility: "auto",
|
|
containIntrinsicSize: "44px",
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
<IssueRow
|
|
issue={issue}
|
|
issueLinkState={issueLinkState}
|
|
checklistStepNumber={checklistStepNumber}
|
|
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
|
|
checklistDependencyChips={checklistDependencyChips}
|
|
checklistRowId={checklistRowId}
|
|
titleClassName={doneRowTitleClass}
|
|
titleSuffix={(
|
|
<>
|
|
{hasChildren && !isExpanded ? (
|
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
|
({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""})
|
|
</span>
|
|
) : null}
|
|
{issueBadge ? (
|
|
issueBadge === "Paused" ? (
|
|
<span
|
|
className={cn("ml-1.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium", statusBadge.paused)}
|
|
aria-label="Paused"
|
|
title="Paused"
|
|
>
|
|
<CircleSlash2 className="h-3 w-3" />
|
|
Paused
|
|
</span>
|
|
) : (
|
|
<span className="ml-1.5 inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300">
|
|
{issueBadge}
|
|
</span>
|
|
)
|
|
) : null}
|
|
{isSuccessfulRunHandoffRequired(issue) ? (
|
|
<span
|
|
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium 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
|
|
</span>
|
|
) : null}
|
|
</>
|
|
)}
|
|
className={isMutedIssue ? "opacity-70" : undefined}
|
|
mobileLeading={
|
|
hasChildren ? (
|
|
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
|
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
|
</button>
|
|
) : (
|
|
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
|
</span>
|
|
)
|
|
}
|
|
desktopMetaLeading={(
|
|
<>
|
|
{hasChildren ? (
|
|
<button
|
|
type="button"
|
|
data-slot="icon-button"
|
|
className="hidden shrink-0 items-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-3.5 shrink-0 sm:block" />
|
|
)}
|
|
<InboxIssueMetaLeading
|
|
issue={issue}
|
|
isLive={liveIssueIds?.has(issue.id) === true}
|
|
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
|
|
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
|
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
|
checklistStepNumber={checklistStepNumber}
|
|
statusSlot={(
|
|
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
|
</span>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
mobileMeta={issueActivityText(issue).toLowerCase()}
|
|
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)}
|
|
assigneeUserName={assigneeUserLabel}
|
|
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? 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) ? (
|
|
<Identity name={agentName(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 assignees..."
|
|
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 assignee
|
|
</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);
|
|
}}
|
|
>
|
|
<Identity name={agent.name} size="sm" className="min-w-0" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
/>
|
|
) : undefined
|
|
)}
|
|
/>
|
|
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null);
|
|
})()}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
|
|
<div className="py-2" data-testid="issues-load-more-sentinel">
|
|
<p className="text-xs text-muted-foreground">
|
|
{isLoadingMoreIssues
|
|
? "Loading more tasks..."
|
|
: remainingIssueRowCount > 0
|
|
? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} tasks`
|
|
: "Scroll to load more tasks"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|