Files
Jérémy Coatelen 538813de02 feat(web): add Threads view for live agent conversations (#3112)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-03 08:07:16 +00:00

267 lines
8.8 KiB
TypeScript

import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import type {
TaskPendingAction,
TaskSessionState,
TaskState,
WorkflowReviewStatus,
} from "@/lib/types/http";
type KanbanTask = KanbanState["tasks"][number];
/**
* One live agent conversation, projected into the shape a Threads column needs.
*
* Derived entirely from the workflow snapshots the board already keeps in the
* store, so opening the Threads view costs no extra request.
*/
export type ActiveThread = {
taskId: string;
title: string;
workflowId: string;
workflowName: string;
/** Null when the task sits in a step the snapshot no longer lists. */
stepTitle: string | null;
sessionId: string;
sessionState: TaskSessionState;
pendingAction: TaskPendingAction | null;
/** Aggregate action across the task, never attributed to `sessionId`. */
taskPendingAction?: TaskPendingAction | null;
taskState?: TaskState | null;
reviewStatus?: WorkflowReviewStatus | null;
activeSubagentCount: number;
queuedPromptCount: number;
lastActivityAt: string | null;
};
/**
* Attention buckets. A thread blocked on a person is the one the user came to
* this view to find, so it sorts ahead of threads the agent is still driving.
*/
const NEEDS_HUMAN = 0;
const WORKING = 1;
const WAITING = 2;
const WORKING_STATES: TaskSessionState[] = ["RUNNING", "STARTING"];
type ThreadSession = {
id: string | null;
state: TaskSessionState | null;
pendingAction: TaskPendingAction | null;
lastActivityAt: string | null;
};
function resolvePendingAction(task: KanbanTask): TaskPendingAction | null {
// The bounded status summary and taskPendingAction are aggregates across
// every session. Threads renders the primary session only, so using either
// aggregate here can attribute a child session's request to the wrong
// conversation.
return task.primarySessionPendingAction ?? null;
}
export function resolveTaskPendingAction(task: KanbanTask): TaskPendingAction | null {
const summaryAction = task.statusSummary?.pending_action;
return summaryAction !== undefined ? summaryAction : (task.taskPendingAction ?? null);
}
function isReviewOutcome(task: KanbanTask): boolean {
return task.state === "REVIEW" || task.reviewStatus === "pending";
}
function resolveThreadBucket(
task: KanbanTask,
session: ThreadSession,
taskPendingAction: TaskPendingAction | null,
): number | null {
const sessionBucket = attentionBucket(session);
if (sessionBucket !== null) return sessionBucket;
if (taskPendingAction) return NEEDS_HUMAN;
if (isReviewOutcome(task)) return WORKING;
return null;
}
function resolveLastActivityAt(task: KanbanTask): string | null {
const summary = task.statusSummary;
return summary?.last_activity_at ?? summary?.updated_at ?? task.updatedAt ?? null;
}
/**
* The status summary is the bounded live projection the backend pushes over
* WebSockets, so it leads; the cached primary-session fields only fill gaps a
* summary-less task would otherwise leave blank.
*/
function resolveThreadSession(task: KanbanTask): ThreadSession {
const summarySession = task.statusSummary?.primary_session ?? null;
return {
id: summarySession?.id ?? task.primarySessionId ?? null,
state: summarySession?.state ?? (task.primarySessionState as TaskSessionState | null) ?? null,
pendingAction: resolvePendingAction(task),
lastActivityAt: resolveLastActivityAt(task),
};
}
function attentionBucket(session: {
state: TaskSessionState | null | undefined;
pendingAction?: TaskPendingAction | null;
}): number | null {
if (session.pendingAction) return NEEDS_HUMAN;
if (session.state && WORKING_STATES.includes(session.state)) return WORKING;
return session.state === "WAITING_FOR_INPUT" ? WAITING : null;
}
export type ThreadTaskEligibilityInput = {
taskState?: TaskState | null;
reviewStatus?: WorkflowReviewStatus | null;
taskPendingAction?: TaskPendingAction | null;
primarySession?: {
state: TaskSessionState | null | undefined;
pendingAction?: TaskPendingAction | null;
} | null;
};
/**
* Whether a task has a column in the Threads deck, independent of which
* conversation a task-detail page currently has selected.
*/
export function isThreadTaskEligible({
taskState,
reviewStatus,
taskPendingAction,
primarySession,
}: ThreadTaskEligibilityInput): boolean {
if (taskPendingAction || taskState === "REVIEW" || reviewStatus === "pending") return true;
return primarySession
? isActiveThreadSession({
isPrimary: true,
state: primarySession.state,
pendingAction: primarySession.pendingAction,
})
: false;
}
/**
* Whether one session is the thread the deck would give a column to.
*
* The deck keys columns by task and renders the task's primary session, so a
* non-primary session has no column of its own however busy it is. Surfaces
* that offer to jump into the deck ask this first, so they never point at a
* column that is not there.
*/
export function isActiveThreadSession(session: {
isPrimary: boolean;
state: TaskSessionState | null | undefined;
pendingAction?: TaskPendingAction | null;
}): boolean {
if (!session.isPrimary) return false;
return attentionBucket(session) !== null;
}
/**
* The column a deep link asked to focus, or null when that task is not in the
* deck — it may have settled between the link being offered and followed.
*/
export function resolveFocusedThreadId(
threads: readonly ActiveThread[],
requestedTaskId: string | null | undefined,
): string | null {
if (!requestedTaskId) return null;
return threads.some((thread) => thread.taskId === requestedTaskId) ? requestedTaskId : null;
}
type ThreadBuildInput = {
task: KanbanTask;
snapshot: WorkflowSnapshotData;
stepTitles: Map<string, string>;
session: ThreadSession;
taskPendingAction: TaskPendingAction | null;
bucket: number;
};
function buildThread({
task,
snapshot,
stepTitles,
session,
taskPendingAction,
bucket,
}: ThreadBuildInput): ActiveThread & { bucket: number } {
return {
bucket,
taskId: task.id,
title: task.title,
workflowId: snapshot.workflowId,
workflowName: snapshot.workflowName,
stepTitle: stepTitles.get(task.workflowStepId) ?? null,
sessionId: session.id as string,
sessionState: session.state as TaskSessionState,
pendingAction: session.pendingAction,
taskPendingAction,
taskState: task.state ?? null,
reviewStatus: task.reviewStatus ?? null,
activeSubagentCount: task.statusSummary?.active_subagent_count ?? task.activeSubagentCount ?? 0,
queuedPromptCount: task.statusSummary?.queued_prompt_count ?? 0,
lastActivityAt: session.lastActivityAt,
};
}
function toThread(
task: KanbanTask,
snapshot: WorkflowSnapshotData,
stepTitles: Map<string, string>,
): (ActiveThread & { bucket: number }) | null {
if (task.isArchived) return null;
const session = resolveThreadSession(task);
const taskPendingAction = resolveTaskPendingAction(task);
if (
!isThreadTaskEligible({
taskState: task.state ?? null,
reviewStatus: task.reviewStatus ?? null,
taskPendingAction,
primarySession: session,
})
) {
return null;
}
const bucket = resolveThreadBucket(task, session, taskPendingAction);
// A thread with no session id has no conversation to render, so a column for
// it would be an empty promise rather than a view of live work.
if (bucket === null || !session.id || !session.state) return null;
return buildThread({ task, snapshot, stepTitles, session, taskPendingAction, bucket });
}
function activityRank(thread: ActiveThread): number {
const parsed = thread.lastActivityAt ? Date.parse(thread.lastActivityAt) : Number.NaN;
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Columns must not reshuffle under the reader between renders, so ordering is
* total: bucket, then recency, then a task-id tiebreak.
*/
function compareThreads(
a: ActiveThread & { bucket: number },
b: ActiveThread & { bucket: number },
): number {
if (a.bucket !== b.bucket) return a.bucket - b.bucket;
const recency = activityRank(b) - activityRank(a);
if (recency !== 0) return recency;
return a.taskId.localeCompare(b.taskId);
}
export function selectActiveThreads(
snapshots: Record<string, WorkflowSnapshotData>,
options: { workflowId?: string | null } = {},
): ActiveThread[] {
const scoped = Object.values(snapshots).filter(
(snapshot) => !options.workflowId || snapshot.workflowId === options.workflowId,
);
const threads = scoped.flatMap((snapshot) => {
const stepTitles = new Map(snapshot.steps.map((step) => [step.id, step.title]));
return snapshot.tasks
.map((task) => toThread(task, snapshot, stepTitles))
.filter((thread): thread is ActiveThread & { bucket: number } => thread !== null);
});
return threads.sort(compareThreads).map(({ bucket: _bucket, ...thread }) => thread);
}