f8654880f3
* fix(web): stop kanban spinner when primary session is terminal The kanban card spinner was gated only on `task.state` being IN_PROGRESS or SCHEDULING. Workflows leave a task in the IN_PROGRESS column after the agent's session reaches a terminal state (COMPLETED/FAILED/CANCELLED) so the user can review and move it manually, which made the loading icon spin indefinitely. The frontend already receives `primary_session_state` over SSR and WS; gate the spinner on that not being terminal. Fixes #985. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(orchestrator): write task.state=REVIEW after engine on_turn_complete transition The non-engine path (setSessionWaitingForInput → writeTaskReviewState) has always written tasks.state = REVIEW when the agent's session paused for input. The workflow-engine on_turn_complete path forgot the task-state write: it persisted the new workflow step + flipped the session to WAITING_FOR_INPUT but left tasks.state stale at IN_PROGRESS. That left the kanban card stuck in IN_PROGRESS — with a spinning loader — even after the workflow had moved it into the "Review" column. Mirror the non-engine path: call writeTaskReviewState inside the RUNNING/STARTING branch of applyEngineTransition. Add a regression test that asserts tasks.state = REVIEW after an engine-driven on_turn_complete transition. Also tighten the frontend gate added in the previous commit to suppress the spinner for any non-active primary-session state (WAITING_FOR_INPUT, IDLE in addition to COMPLETED/FAILED/CANCELLED). The backend fix is the real correction; the broader frontend gate stays as defense-in-depth against future state-sync drift. Refs #985. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(orchestrator): drop redundant time.Sleep in engine on_turn_complete test writeTaskReviewState and ApplyTransition both run synchronously inside applyEngineTransition (before processOnEnter's goroutine), so the task-state and step assertions need no async wait. assertSessionState already has its own 500ms polling loop for the WAITING_FOR_INPUT flip. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
import type { ComponentType } from "react";
|
|
import {
|
|
IconAlertCircle,
|
|
IconAlertTriangle,
|
|
IconCheck,
|
|
IconCircleFilled,
|
|
IconCircleCheck,
|
|
IconClock,
|
|
IconLoader2,
|
|
IconMessageQuestion,
|
|
IconPlayerPause,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import type { TaskSessionState, TaskState } from "@/lib/types/http";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type IconConfig = {
|
|
Icon: ComponentType<{ className?: string }>;
|
|
className: string;
|
|
};
|
|
|
|
const STYLE_MUTED = "text-muted-foreground";
|
|
const STYLE_LOADING = "text-blue-500 animate-spin";
|
|
const STYLE_WARNING = "text-yellow-500";
|
|
const STYLE_ERROR = "text-red-500";
|
|
const WAITING_FOR_INPUT = "WAITING_FOR_INPUT";
|
|
|
|
const TASK_STATE_ICONS: Record<TaskState, IconConfig> = {
|
|
CREATED: { Icon: IconAlertCircle, className: STYLE_MUTED },
|
|
SCHEDULING: { Icon: IconLoader2, className: STYLE_LOADING },
|
|
IN_PROGRESS: { Icon: IconLoader2, className: STYLE_LOADING },
|
|
REVIEW: { Icon: IconCheck, className: STYLE_WARNING },
|
|
BLOCKED: { Icon: IconAlertCircle, className: STYLE_WARNING },
|
|
WAITING_FOR_INPUT: { Icon: IconMessageQuestion, className: STYLE_WARNING },
|
|
COMPLETED: { Icon: IconCheck, className: "text-green-500" },
|
|
FAILED: { Icon: IconX, className: STYLE_ERROR },
|
|
CANCELLED: { Icon: IconX, className: STYLE_ERROR },
|
|
TODO: { Icon: IconAlertCircle, className: STYLE_MUTED },
|
|
};
|
|
|
|
const SESSION_STATE_ICONS: Record<TaskSessionState, IconConfig> = {
|
|
CREATED: { Icon: IconAlertCircle, className: STYLE_MUTED },
|
|
STARTING: { Icon: IconLoader2, className: STYLE_LOADING },
|
|
RUNNING: { Icon: IconCircleFilled, className: "text-emerald-500" },
|
|
// Office sessions: agent process torn down, conversation paused. Use the
|
|
// pause icon — visually distinct from RUNNING and from terminal states.
|
|
IDLE: { Icon: IconPlayerPause, className: STYLE_MUTED },
|
|
WAITING_FOR_INPUT: { Icon: IconClock, className: STYLE_MUTED },
|
|
COMPLETED: { Icon: IconCircleCheck, className: "text-green-500" },
|
|
FAILED: { Icon: IconAlertTriangle, className: STYLE_ERROR },
|
|
CANCELLED: { Icon: IconPlayerPause, className: STYLE_MUTED },
|
|
};
|
|
|
|
const DEFAULT_TASK_ICON: IconConfig = {
|
|
Icon: IconAlertCircle,
|
|
className: STYLE_MUTED,
|
|
};
|
|
|
|
const DEFAULT_SESSION_ICON: IconConfig = {
|
|
Icon: IconAlertCircle,
|
|
className: STYLE_MUTED,
|
|
};
|
|
|
|
export function isWaitingForInputState(state?: TaskState): boolean {
|
|
return state === WAITING_FOR_INPUT;
|
|
}
|
|
|
|
export function shouldUseQuestionTaskIcon(
|
|
state?: TaskState,
|
|
hasPendingClarification = false,
|
|
): boolean {
|
|
return isWaitingForInputState(state) || hasPendingClarification;
|
|
}
|
|
|
|
// Session states where the agent is actively running work. Anything outside
|
|
// this set (WAITING_FOR_INPUT, IDLE, COMPLETED, FAILED, CANCELLED) is paused
|
|
// or terminal and must not drive the spinner — even when the task is still
|
|
// in the IN_PROGRESS workflow column.
|
|
const ACTIVE_SESSION_STATES: ReadonlySet<string> = new Set<TaskSessionState>([
|
|
"CREATED",
|
|
"STARTING",
|
|
"RUNNING",
|
|
]);
|
|
|
|
/**
|
|
* Returns true when the kanban card should show the spinning loader for an
|
|
* in-progress task. The task workflow state and the primary session's runtime
|
|
* state are decoupled — the workflow can keep a task in `IN_PROGRESS` after
|
|
* the agent has finished, errored, or paused waiting for input — so we gate
|
|
* the spinner on the primary session being actively running.
|
|
*
|
|
* When no primary session is attached yet (task just created / scheduling),
|
|
* we still show the spinner so users see the imminent work; otherwise we
|
|
* require an active session state.
|
|
*/
|
|
export function shouldShowTaskRunningSpinner(
|
|
taskState?: TaskState,
|
|
primarySessionState?: string | null,
|
|
): boolean {
|
|
if (taskState !== "IN_PROGRESS" && taskState !== "SCHEDULING") return false;
|
|
if (!primarySessionState) return true;
|
|
return ACTIVE_SESSION_STATES.has(primarySessionState);
|
|
}
|
|
|
|
export function shouldUsePermissionTaskIcon(hasPendingPermission = false): boolean {
|
|
return hasPendingPermission;
|
|
}
|
|
|
|
function getTaskStateIconConfig(state?: TaskState, hasPendingClarification = false): IconConfig {
|
|
if (shouldUseQuestionTaskIcon(state, hasPendingClarification)) {
|
|
return TASK_STATE_ICONS.WAITING_FOR_INPUT;
|
|
}
|
|
if (!state) return DEFAULT_TASK_ICON;
|
|
return TASK_STATE_ICONS[state] ?? DEFAULT_TASK_ICON;
|
|
}
|
|
|
|
export function getTaskStateIcon(
|
|
state?: TaskState,
|
|
className?: string,
|
|
hasPendingClarification = false,
|
|
) {
|
|
const config = getTaskStateIconConfig(state, hasPendingClarification);
|
|
return <config.Icon className={cn("h-4 w-4", config.className, className)} />;
|
|
}
|
|
|
|
export function getSessionStateIcon(state?: TaskSessionState, className?: string) {
|
|
const config = state
|
|
? (SESSION_STATE_ICONS[state] ?? DEFAULT_SESSION_ICON)
|
|
: DEFAULT_SESSION_ICON;
|
|
return <config.Icon className={cn("h-4 w-4", config.className, className)} />;
|
|
}
|