Files
kandev/apps/web/lib/ui/state-icons.tsx
T
João Salavisa f8654880f3 fix(kanban): stop stuck spinner by writing task.state=REVIEW on engine on_turn_complete (#1001)
* 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>
2026-05-21 22:58:14 +01:00

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)} />;
}