504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
|
import type { ForegroundActivity, TaskPriority, TaskState } from "@/lib/types/http";
|
|
import { taskPRInfoFromSummary, type TaskPRInfo } from "@/lib/task-pr-info";
|
|
import { selectActiveThreads, type ActiveThread } from "./active-threads";
|
|
import type {
|
|
ThreadFilterClause,
|
|
ThreadFilterDimension,
|
|
ThreadSortKey,
|
|
ThreadView,
|
|
ThreadViewDraft,
|
|
} from "@/lib/state/slices/ui/thread-view-types";
|
|
|
|
type KanbanTask = KanbanState["tasks"][number];
|
|
|
|
export type ThreadStatus = "needs_action" | "running" | "waiting" | "ready_for_review";
|
|
export type ThreadTaskType = "standard" | "pull_request_review" | "issue_watch";
|
|
|
|
/** Bounded task data used by the Threads query and header controls. */
|
|
export type ThreadCandidate = ActiveThread & {
|
|
workflowStepId: string;
|
|
taskState: TaskState | null;
|
|
foregroundActivity: ForegroundActivity | null;
|
|
interrupted: boolean;
|
|
isOnLastWorkflowStep: boolean;
|
|
priority: TaskPriority | null;
|
|
blocked: boolean;
|
|
labels: string[];
|
|
taskOrigin: string;
|
|
taskType: ThreadTaskType;
|
|
repositoryIds: string[];
|
|
primaryAgentProfileId: string | null;
|
|
primaryAgentName: string | null;
|
|
executorType: string | null;
|
|
sessionCount: number;
|
|
threadStatus: ThreadStatus;
|
|
hasDiff: boolean;
|
|
hasPR: boolean;
|
|
prInfo?: TaskPRInfo;
|
|
prNeedsAttention: boolean;
|
|
hasActiveError: boolean;
|
|
hasMultipleSessions: boolean;
|
|
updatedAt: string | null;
|
|
createdAt: string | null;
|
|
};
|
|
|
|
export type ThreadViewQueryOptions = {
|
|
workspaceId?: string | null;
|
|
requestedTaskId?: string | null;
|
|
draft?: ThreadViewDraft | null;
|
|
/** Transient removal intent applies before every admission path, including deep links. */
|
|
excludedTaskIds?: ReadonlySet<string>;
|
|
};
|
|
|
|
export type ThreadViewQueryResult = {
|
|
candidates: ThreadCandidate[];
|
|
/** Candidates that satisfy the saved scope and every active filter. */
|
|
matchingCandidates: ThreadCandidate[];
|
|
/** Complete current set used to retain admitted columns during live updates. */
|
|
stableCandidates: ThreadCandidate[];
|
|
admittedCandidates: ThreadCandidate[];
|
|
matchingCount: number;
|
|
/** Number of temporary deep-link candidates outside the saved query. */
|
|
temporaryAdmissionCount: number;
|
|
hiddenCount: number;
|
|
effectiveView: ThreadView;
|
|
fingerprint: string;
|
|
};
|
|
|
|
function taskOrigin(task: KanbanTask): string {
|
|
if (task.origin) return task.origin;
|
|
const metadataOrigin = task.metadata?.origin;
|
|
return typeof metadataOrigin === "string" && metadataOrigin.length > 0
|
|
? metadataOrigin
|
|
: "unknown";
|
|
}
|
|
|
|
function taskType(task: KanbanTask): ThreadTaskType {
|
|
if (task.isPRReview) return "pull_request_review";
|
|
if (task.isIssueWatch) return "issue_watch";
|
|
return "standard";
|
|
}
|
|
|
|
function hasGitChange(task: KanbanTask): boolean {
|
|
const git = task.statusSummary?.git;
|
|
return Boolean(
|
|
git && ((git.changed_files ?? 0) > 0 || (git.additions ?? 0) > 0 || (git.deletions ?? 0) > 0),
|
|
);
|
|
}
|
|
|
|
function hasPullRequest(task: KanbanTask): boolean {
|
|
return (task.statusSummary?.pull_request?.count ?? 0) > 0;
|
|
}
|
|
|
|
function resolveThreadStatus(thread: ActiveThread): ThreadStatus {
|
|
if (thread.pendingAction || thread.taskPendingAction) return "needs_action";
|
|
if (thread.taskState === "REVIEW" || thread.reviewStatus === "pending") {
|
|
return "ready_for_review";
|
|
}
|
|
if (thread.sessionState === "RUNNING" || thread.sessionState === "STARTING") {
|
|
return "running";
|
|
}
|
|
return "waiting";
|
|
}
|
|
|
|
// eslint-disable-next-line complexity -- Projects the complete bounded candidate contract from two authoritative task sources.
|
|
function projectCandidate(
|
|
task: KanbanTask,
|
|
thread: ActiveThread,
|
|
isOnLastWorkflowStep: boolean,
|
|
): ThreadCandidate {
|
|
const summary = task.statusSummary;
|
|
const sessionCount = Math.max(1, task.sessionCount ?? 1);
|
|
return {
|
|
...thread,
|
|
workflowStepId: task.workflowStepId,
|
|
taskState: task.state ?? null,
|
|
foregroundActivity: summary?.foreground_activity ?? task.foregroundActivity ?? null,
|
|
interrupted: task.interrupted === true,
|
|
isOnLastWorkflowStep,
|
|
priority: task.priority ?? null,
|
|
blocked: task.blocked ?? false,
|
|
labels: [...(task.labels ?? [])],
|
|
taskOrigin: taskOrigin(task),
|
|
taskType: taskType(task),
|
|
repositoryIds: (task.repositories ?? [])
|
|
.map((repository) => repository.repository_id)
|
|
.filter((repositoryId): repositoryId is string => repositoryId.length > 0),
|
|
primaryAgentProfileId: task.primaryAgentProfileId ?? null,
|
|
primaryAgentName: task.primaryAgentName ?? null,
|
|
executorType: task.primaryExecutorType ?? null,
|
|
sessionCount,
|
|
threadStatus: resolveThreadStatus(thread),
|
|
hasDiff: hasGitChange(task),
|
|
hasPR: hasPullRequest(task),
|
|
prInfo: taskPRInfoFromSummary(summary),
|
|
prNeedsAttention: task.statusSummary?.pull_request?.attention === true,
|
|
hasActiveError: task.statusSummary?.active_error != null,
|
|
hasMultipleSessions: sessionCount > 1,
|
|
updatedAt: task.updatedAt ?? summary?.updated_at ?? null,
|
|
createdAt: task.createdAt ?? null,
|
|
};
|
|
}
|
|
|
|
function taskMapForSnapshots(
|
|
snapshots: Record<string, WorkflowSnapshotData>,
|
|
workspaceId?: string | null,
|
|
): Map<string, KanbanTask> {
|
|
const tasks = new Map<string, KanbanTask>();
|
|
for (const snapshot of Object.values(snapshots)) {
|
|
for (const task of snapshot.tasks) {
|
|
if (workspaceId && task.workspaceId && task.workspaceId !== workspaceId) continue;
|
|
tasks.set(task.id, task);
|
|
}
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
function lastWorkflowStepId(snapshot: WorkflowSnapshotData): string | null {
|
|
let lastStep: WorkflowSnapshotData["steps"][number] | undefined;
|
|
for (const step of snapshot.steps) {
|
|
if (!lastStep || step.position > lastStep.position) lastStep = step;
|
|
}
|
|
return lastStep?.id ?? null;
|
|
}
|
|
|
|
/** Project the current workspace's eligible tasks without applying a workflow filter. */
|
|
export function selectThreadCandidates(
|
|
snapshots: Record<string, WorkflowSnapshotData>,
|
|
options: Pick<ThreadViewQueryOptions, "workspaceId"> = {},
|
|
): ThreadCandidate[] {
|
|
const tasks = taskMapForSnapshots(snapshots, options.workspaceId);
|
|
const lastStepIdByWorkflowId = new Map(
|
|
Object.values(snapshots).map((snapshot) => [snapshot.workflowId, lastWorkflowStepId(snapshot)]),
|
|
);
|
|
return selectActiveThreads(snapshots)
|
|
.map((thread) => {
|
|
const task = tasks.get(thread.taskId);
|
|
return task
|
|
? projectCandidate(
|
|
task,
|
|
thread,
|
|
lastStepIdByWorkflowId.get(thread.workflowId) === task.workflowStepId,
|
|
)
|
|
: null;
|
|
})
|
|
.filter((candidate): candidate is ThreadCandidate => candidate !== null);
|
|
}
|
|
|
|
// eslint-disable-next-line complexity -- The dimension registry is intentionally exhaustive and keeps filter evaluation type-safe.
|
|
function candidateValue(
|
|
candidate: ThreadCandidate,
|
|
dimension: ThreadFilterDimension,
|
|
): string | string[] | boolean {
|
|
switch (dimension) {
|
|
case "threadStatus":
|
|
return candidate.threadStatus;
|
|
case "pendingAction":
|
|
return candidate.pendingAction ?? candidate.taskPendingAction ?? "none";
|
|
case "taskState":
|
|
return candidate.taskState ?? "unknown";
|
|
case "workflow":
|
|
return candidate.workflowId;
|
|
case "workflowStep":
|
|
return candidate.workflowStepId;
|
|
case "repository":
|
|
return candidate.repositoryIds;
|
|
case "primaryAgent":
|
|
return candidate.primaryAgentProfileId ?? "unknown";
|
|
case "executorType":
|
|
return candidate.executorType ?? "unknown";
|
|
case "priority":
|
|
return candidate.priority ?? "unknown";
|
|
case "blocked":
|
|
return candidate.blocked;
|
|
case "hasQueuedPrompts":
|
|
return candidate.queuedPromptCount > 0;
|
|
case "hasActiveSubagents":
|
|
return candidate.activeSubagentCount > 0;
|
|
case "hasDiff":
|
|
return candidate.hasDiff;
|
|
case "hasPR":
|
|
return candidate.hasPR;
|
|
case "prNeedsAttention":
|
|
return candidate.prNeedsAttention;
|
|
case "taskType":
|
|
return candidate.taskType;
|
|
case "titleMatch":
|
|
return candidate.title;
|
|
case "hasActiveError":
|
|
return candidate.hasActiveError;
|
|
case "taskLabel":
|
|
return candidate.labels;
|
|
case "taskOrigin":
|
|
return candidate.taskOrigin;
|
|
case "hasMultipleSessions":
|
|
return candidate.hasMultipleSessions;
|
|
}
|
|
}
|
|
|
|
type FilterScalar = string | boolean;
|
|
|
|
function filterValues(value: ThreadFilterClause["value"]): FilterScalar[] {
|
|
return Array.isArray(value) ? value : [value];
|
|
}
|
|
|
|
function scalarEquals(actual: string | boolean, expected: FilterScalar): boolean {
|
|
return actual === expected;
|
|
}
|
|
|
|
function collectionContains(actual: string | string[] | boolean, expected: FilterScalar): boolean {
|
|
return Array.isArray(actual)
|
|
? actual.some((item) => scalarEquals(item, expected))
|
|
: scalarEquals(actual, expected);
|
|
}
|
|
|
|
function matchesText(actual: string | string[] | boolean, expected: FilterScalar): boolean {
|
|
if (typeof expected !== "string") return false;
|
|
const needle = expected.toLocaleLowerCase();
|
|
const values = Array.isArray(actual) ? actual : [actual];
|
|
return values.some(
|
|
(value) => typeof value === "string" && value.toLocaleLowerCase().includes(needle),
|
|
);
|
|
}
|
|
|
|
function matchesClause(candidate: ThreadCandidate, clause: ThreadFilterClause): boolean {
|
|
const actual = candidateValue(candidate, clause.dimension);
|
|
const values = filterValues(clause.value);
|
|
switch (clause.op) {
|
|
case "is":
|
|
return values.some((value) => collectionContains(actual, value));
|
|
case "is_not":
|
|
return values.every((value) => !collectionContains(actual, value));
|
|
case "in":
|
|
return values.some((value) => collectionContains(actual, value));
|
|
case "not_in":
|
|
return values.every((value) => !collectionContains(actual, value));
|
|
case "matches":
|
|
return values.some((value) => matchesText(actual, value));
|
|
case "not_matches":
|
|
return values.every((value) => !matchesText(actual, value));
|
|
}
|
|
}
|
|
|
|
function candidateMatches(candidate: ThreadCandidate, filters: ThreadFilterClause[]): boolean {
|
|
return filters.every((filter) => matchesClause(candidate, filter));
|
|
}
|
|
|
|
const PRIORITY_RANK: Record<string, number> = {
|
|
critical: 0,
|
|
high: 1,
|
|
medium: 2,
|
|
low: 3,
|
|
};
|
|
|
|
const TASK_STATE_RANK: Record<string, number> = {
|
|
CREATED: 0,
|
|
SCHEDULING: 1,
|
|
TODO: 2,
|
|
IN_PROGRESS: 3,
|
|
WAITING_FOR_INPUT: 4,
|
|
BLOCKED: 5,
|
|
REVIEW: 6,
|
|
COMPLETED: 7,
|
|
FAILED: 8,
|
|
CANCELLED: 9,
|
|
};
|
|
|
|
function compareStrings(left: string | null, right: string | null): number {
|
|
if (left === right) return 0;
|
|
if (left === null) return 1;
|
|
if (right === null) return -1;
|
|
return left.localeCompare(right);
|
|
}
|
|
|
|
function compareNumbers(left: number, right: number): number {
|
|
if (left === right) return 0;
|
|
return left < right ? -1 : 1;
|
|
}
|
|
|
|
function dateRank(value: string | null): number | null {
|
|
if (!value) return null;
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
function compareDates(left: string | null, right: string | null): number {
|
|
const leftRank = dateRank(left);
|
|
const rightRank = dateRank(right);
|
|
if (leftRank === null || rightRank === null) return compareStrings(left, right);
|
|
return compareNumbers(leftRank, rightRank);
|
|
}
|
|
|
|
function compareRecentDates(left: string | null, right: string | null): number {
|
|
const leftRank = dateRank(left);
|
|
const rightRank = dateRank(right);
|
|
if (leftRank === null) return rightRank === null ? compareStrings(left, right) : 1;
|
|
if (rightRank === null) return -1;
|
|
return compareNumbers(rightRank, leftRank);
|
|
}
|
|
|
|
function attentionRank(status: ThreadStatus): number {
|
|
switch (status) {
|
|
case "needs_action":
|
|
return 0;
|
|
case "running":
|
|
case "ready_for_review":
|
|
return 1;
|
|
case "waiting":
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line complexity -- Each supported sort key has an explicit deterministic comparison.
|
|
function compareSortKey(left: ThreadCandidate, right: ThreadCandidate, key: ThreadSortKey): number {
|
|
switch (key) {
|
|
case "attention": {
|
|
const bucket = compareNumbers(
|
|
attentionRank(left.threadStatus),
|
|
attentionRank(right.threadStatus),
|
|
);
|
|
return bucket !== 0 ? bucket : compareRecentDates(left.lastActivityAt, right.lastActivityAt);
|
|
}
|
|
case "lastActivityAt":
|
|
return compareDates(left.lastActivityAt, right.lastActivityAt);
|
|
case "updatedAt":
|
|
return compareDates(left.updatedAt, right.updatedAt);
|
|
case "createdAt":
|
|
return compareDates(left.createdAt, right.createdAt);
|
|
case "title":
|
|
return left.title.localeCompare(right.title);
|
|
case "taskState":
|
|
return compareNumbers(
|
|
TASK_STATE_RANK[left.taskState ?? ""] ?? Number.MAX_SAFE_INTEGER,
|
|
TASK_STATE_RANK[right.taskState ?? ""] ?? Number.MAX_SAFE_INTEGER,
|
|
);
|
|
case "workflow": {
|
|
const workflow = left.workflowName.localeCompare(right.workflowName);
|
|
return workflow !== 0
|
|
? workflow
|
|
: (left.stepTitle?.localeCompare(right.stepTitle ?? "") ?? 0);
|
|
}
|
|
case "priority":
|
|
return compareNumbers(
|
|
PRIORITY_RANK[left.priority ?? ""] ?? Number.MAX_SAFE_INTEGER,
|
|
PRIORITY_RANK[right.priority ?? ""] ?? Number.MAX_SAFE_INTEGER,
|
|
);
|
|
case "primaryAgent":
|
|
return compareStrings(
|
|
left.primaryAgentName ?? left.primaryAgentProfileId,
|
|
right.primaryAgentName ?? right.primaryAgentProfileId,
|
|
);
|
|
}
|
|
}
|
|
|
|
function compareCandidates(
|
|
left: ThreadCandidate,
|
|
right: ThreadCandidate,
|
|
view: ThreadView,
|
|
): number {
|
|
const direction = view.sort.direction === "desc" ? -1 : 1;
|
|
const sorted = compareSortKey(left, right, view.sort.key);
|
|
if (sorted !== 0) return sorted * direction;
|
|
return left.taskId.localeCompare(right.taskId);
|
|
}
|
|
|
|
function cloneViewWithDraft(
|
|
view: ThreadView,
|
|
draft: ThreadViewDraft | null | undefined,
|
|
): ThreadView {
|
|
if (!draft || draft.baseViewId !== view.id) return view;
|
|
return {
|
|
...view,
|
|
taskScope:
|
|
draft.taskScope.mode === "all"
|
|
? { mode: "all", taskIds: [] }
|
|
: { mode: "selected", taskIds: [...draft.taskScope.taskIds] },
|
|
filters: draft.filters.map((filter) => ({
|
|
...filter,
|
|
value: Array.isArray(filter.value) ? [...filter.value] : filter.value,
|
|
})),
|
|
sort: { ...draft.sort },
|
|
maxColumns: draft.maxColumns,
|
|
layout: draft.layout,
|
|
autoHideComposer: draft.autoHideComposer,
|
|
};
|
|
}
|
|
|
|
function queryFingerprint(view: ThreadView): string {
|
|
return JSON.stringify({
|
|
id: view.id,
|
|
taskScope: view.taskScope,
|
|
filters: view.filters,
|
|
sort: view.sort,
|
|
maxColumns: view.maxColumns,
|
|
});
|
|
}
|
|
|
|
function applyTaskScope(
|
|
candidates: ThreadCandidate[],
|
|
scope: ThreadView["taskScope"],
|
|
): ThreadCandidate[] {
|
|
if (scope.mode === "all") return candidates;
|
|
const selected = new Set(scope.taskIds);
|
|
return candidates.filter((candidate) => selected.has(candidate.taskId));
|
|
}
|
|
|
|
function admitCandidates(
|
|
matching: ThreadCandidate[],
|
|
deepLinked: ThreadCandidate | null,
|
|
maxColumns: number | null,
|
|
): ThreadCandidate[] {
|
|
const ordinary = maxColumns === null ? [...matching] : matching.slice(0, maxColumns);
|
|
if (!deepLinked || ordinary.some((candidate) => candidate.taskId === deepLinked.taskId)) {
|
|
return ordinary;
|
|
}
|
|
if (maxColumns === null || ordinary.length < maxColumns) return [...ordinary, deepLinked];
|
|
return [...ordinary.slice(0, -1), deepLinked];
|
|
}
|
|
|
|
/** Apply the effective Threads view and return only the task columns to mount. */
|
|
export function queryThreadView(
|
|
snapshots: Record<string, WorkflowSnapshotData>,
|
|
view: ThreadView,
|
|
options: ThreadViewQueryOptions = {},
|
|
): ThreadViewQueryResult {
|
|
const effectiveView = cloneViewWithDraft(view, options.draft);
|
|
const candidates = selectThreadCandidates(snapshots, options).filter(
|
|
(candidate) => !options.excludedTaskIds?.has(candidate.taskId),
|
|
);
|
|
const scoped = applyTaskScope(candidates, effectiveView.taskScope);
|
|
const matchingCandidates = scoped
|
|
.filter((candidate) => candidateMatches(candidate, effectiveView.filters))
|
|
.sort((left, right) => compareCandidates(left, right, effectiveView));
|
|
const deepLinkCandidate = options.requestedTaskId
|
|
? (candidates.find((candidate) => candidate.taskId === options.requestedTaskId) ?? null)
|
|
: null;
|
|
const admittedCandidates = admitCandidates(
|
|
matchingCandidates,
|
|
deepLinkCandidate,
|
|
effectiveView.maxColumns,
|
|
);
|
|
const matchingIds = new Set(matchingCandidates.map((candidate) => candidate.taskId));
|
|
const isTemporaryAdmission =
|
|
deepLinkCandidate !== null && !matchingIds.has(deepLinkCandidate.taskId);
|
|
const stableCandidates = isTemporaryAdmission
|
|
? [...matchingCandidates, deepLinkCandidate]
|
|
: matchingCandidates;
|
|
const admittedIds = new Set(admittedCandidates.map((candidate) => candidate.taskId));
|
|
const hiddenCount = matchingCandidates.filter(
|
|
(candidate) => !admittedIds.has(candidate.taskId),
|
|
).length;
|
|
return {
|
|
candidates,
|
|
matchingCandidates,
|
|
stableCandidates,
|
|
admittedCandidates,
|
|
matchingCount: matchingCandidates.length,
|
|
temporaryAdmissionCount: isTemporaryAdmission ? 1 : 0,
|
|
hiddenCount,
|
|
effectiveView,
|
|
fingerprint: queryFingerprint(effectiveView),
|
|
};
|
|
}
|