feat: show question icon only for pending input (#782)
* fix: show question icon only for pending input * fix: address pending input review feedback * fix: reduce pending input sidebar complexity * fix: address clarification review comments * test: cover pending clarification hook
This commit is contained in:
@@ -28,12 +28,13 @@ import {
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import type { TaskState, Repository } from "@/lib/types/http";
|
||||
import { cn, getRepositoryDisplayName } from "@/lib/utils";
|
||||
import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
||||
import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
|
||||
import { needsAction } from "@/lib/utils/needs-action";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { PRTaskIcon } from "@/components/github/pr-task-icon";
|
||||
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -249,13 +250,16 @@ function KanbanCardActions({
|
||||
>) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4");
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest);
|
||||
const hasKnownSession =
|
||||
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING") && statusIcon}
|
||||
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) &&
|
||||
statusIcon}
|
||||
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { Task } from "@/components/kanban-card";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
|
||||
|
||||
type StepPhase = "past" | "current" | "future";
|
||||
|
||||
@@ -100,6 +101,7 @@ export function Graph2StepNode({
|
||||
}: Graph2StepNodeProps) {
|
||||
const router = useRouter();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
|
||||
if (phase === "past") return <PastNode step={step} />;
|
||||
if (phase === "future") return <FutureNode step={step} />;
|
||||
@@ -138,7 +140,9 @@ export function Graph2StepNode({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
<div className="shrink-0">{getTaskStateIcon(task.state, "h-3 w-3")}</div>
|
||||
<div className="shrink-0">
|
||||
{getTaskStateIcon(task.state, "h-3 w-3", hasPendingClarificationRequest)}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-foreground truncate">{step.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Task } from "@/components/kanban-card";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
|
||||
|
||||
export type SwimlaneGraphContentProps = {
|
||||
workflowId: string;
|
||||
@@ -72,7 +73,8 @@ function DraggableTaskChip({
|
||||
id: task.id,
|
||||
});
|
||||
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3");
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", hasPendingClarificationRequest);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -98,7 +100,8 @@ function DraggableTaskChip({
|
||||
}
|
||||
|
||||
function TaskChipPreview({ task }: { task: Task }) {
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3");
|
||||
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
|
||||
const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", hasPendingClarificationRequest);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTasks } from "@/hooks/use-tasks";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { getSessionInfoForTask } from "@/lib/utils/session-info";
|
||||
import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification";
|
||||
import type {
|
||||
TaskState,
|
||||
TaskSessionState,
|
||||
@@ -85,6 +86,7 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) {
|
||||
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
|
||||
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
|
||||
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
||||
const messagesBySession = useAppStore((state) => state.messages.bySession);
|
||||
const { tasks } = useTasks(workflowId);
|
||||
const steps = useAppStore((state) => state.kanban.steps);
|
||||
const workspaces = useAppStore((state) => state.workspaces.items);
|
||||
@@ -123,6 +125,10 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) {
|
||||
remoteExecutorType: task.primaryExecutorType ?? undefined,
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
primarySessionId: task.primarySessionId ?? null,
|
||||
hasPendingClarification: hasPendingClarificationForSession(
|
||||
messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [
|
||||
@@ -132,6 +138,7 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) {
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
messagesBySession,
|
||||
]);
|
||||
|
||||
const dialogSteps = useMemo(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { TaskItem } from "./task-item";
|
||||
|
||||
const REVIEW_ICON_TEST_ID = "task-state-review";
|
||||
const WAITING_FOR_INPUT_ICON_TEST_ID = "task-state-waiting-for-input";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function renderTaskItem(props: Partial<ComponentProps<typeof TaskItem>> = {}) {
|
||||
return render(
|
||||
<StateProvider>
|
||||
<TaskItem title="Needs answer" state="REVIEW" {...props} />
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskItem status icon", () => {
|
||||
it("keeps the review check when the session is idle after a turn", () => {
|
||||
renderTaskItem({ sessionState: "WAITING_FOR_INPUT" });
|
||||
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).not.toBeNull();
|
||||
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows question icon when a clarification is pending", () => {
|
||||
renderTaskItem({ sessionState: "WAITING_FOR_INPUT", hasPendingClarification: true });
|
||||
|
||||
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).not.toBeNull();
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows question icon when task state is waiting for input", () => {
|
||||
renderTaskItem({ state: "WAITING_FOR_INPUT", hasPendingClarification: false });
|
||||
|
||||
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).not.toBeNull();
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the review check for completed review tasks", () => {
|
||||
renderTaskItem({ sessionState: "COMPLETED" });
|
||||
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).not.toBeNull();
|
||||
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconCircleDashed,
|
||||
IconDots,
|
||||
IconGitPullRequest,
|
||||
IconMessageQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
import { PRTaskIcon } from "@/components/github/pr-task-icon";
|
||||
import { IssueTaskIcon } from "@/components/github/issue-task-icon";
|
||||
@@ -14,6 +15,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEBUG_UI } from "@/lib/config";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
|
||||
import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
|
||||
@@ -41,6 +43,7 @@ type TaskItemProps = {
|
||||
isDeleting?: boolean;
|
||||
taskId?: string;
|
||||
primarySessionId?: string | null;
|
||||
hasPendingClarification?: boolean;
|
||||
parentTaskTitle?: string;
|
||||
isSubTask?: boolean;
|
||||
/** Number of subtasks under this parent task. Only set for parent rows. */
|
||||
@@ -89,11 +92,21 @@ function TaskStateIcon({
|
||||
sessionState,
|
||||
state,
|
||||
isInProgress,
|
||||
hasPendingClarification,
|
||||
}: {
|
||||
sessionState?: TaskSessionState;
|
||||
state?: TaskState;
|
||||
isInProgress: boolean;
|
||||
hasPendingClarification?: boolean;
|
||||
}) {
|
||||
if (shouldUseQuestionTaskIcon(state, hasPendingClarification)) {
|
||||
return (
|
||||
<IconMessageQuestion
|
||||
data-testid="task-state-waiting-for-input"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-yellow-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isInProgress) {
|
||||
return (
|
||||
<IconCircleDashed
|
||||
@@ -275,6 +288,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
isDeleting,
|
||||
taskId,
|
||||
primarySessionId,
|
||||
hasPendingClarification,
|
||||
isSubTask,
|
||||
subtaskCount,
|
||||
subtasksCollapsed,
|
||||
@@ -313,7 +327,12 @@ export const TaskItem = memo(function TaskItem({
|
||||
↳
|
||||
</span>
|
||||
)}
|
||||
<TaskStateIcon sessionState={sessionState} state={state} isInProgress={isInProgress} />
|
||||
<TaskStateIcon
|
||||
sessionState={sessionState}
|
||||
state={state}
|
||||
isInProgress={isInProgress}
|
||||
hasPendingClarification={hasPendingClarification}
|
||||
/>
|
||||
<TaskItemContent
|
||||
title={title}
|
||||
taskId={taskId}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, memo } from "react";
|
||||
import type { TaskState, TaskSession, TaskSessionState, Repository } from "@/lib/types/http";
|
||||
import type {
|
||||
Message,
|
||||
TaskState,
|
||||
TaskSession,
|
||||
TaskSessionState,
|
||||
Repository,
|
||||
} from "@/lib/types/http";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
||||
@@ -22,6 +28,7 @@ import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useArchivedTaskState } from "./task-archived-context";
|
||||
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification";
|
||||
|
||||
/**
|
||||
* Stabilize a derived array of primary session IDs so the reference only
|
||||
@@ -95,6 +102,7 @@ type SidebarCtx = {
|
||||
envIdBySessionId: Record<string, string>;
|
||||
repositorySlugById: Map<string, string | undefined>;
|
||||
taskPRsByTaskId: Record<string, TaskPR[] | undefined>;
|
||||
messagesBySession: Record<string, Message[]>;
|
||||
titleById: Map<string, string>;
|
||||
workflowNameById: Map<string, string>;
|
||||
stepTitleById: Map<string, string>;
|
||||
@@ -124,6 +132,10 @@ function toSidebarItem(
|
||||
const repoSlug = task.repositoryId ? ctx.repositorySlugById.get(task.repositoryId) : undefined;
|
||||
// Sidebar shows just one slot; pick the primary PR (first by created_at).
|
||||
const pr = ctx.taskPRsByTaskId[task.id]?.[0];
|
||||
const hasPendingClarificationRequest = hasPendingClarificationForSession(
|
||||
ctx.messagesBySession,
|
||||
task.primarySessionId,
|
||||
);
|
||||
|
||||
const diffStats = resolveDiffStats(
|
||||
sessionInfo.diffStats,
|
||||
@@ -150,6 +162,7 @@ function toSidebarItem(
|
||||
remoteExecutorType: task.primaryExecutorType ?? undefined,
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
primarySessionId: task.primarySessionId ?? null,
|
||||
hasPendingClarification: hasPendingClarificationRequest,
|
||||
updatedAt: sessionInfo.updatedAt ?? task.updatedAt ?? task.createdAt,
|
||||
createdAt: task.createdAt,
|
||||
isArchived: false as boolean,
|
||||
@@ -187,6 +200,7 @@ function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarI
|
||||
remoteExecutorType: undefined,
|
||||
remoteExecutorName: undefined,
|
||||
primarySessionId: null,
|
||||
hasPendingClarification: false,
|
||||
updatedAt: s.archivedTaskUpdatedAt,
|
||||
createdAt: undefined,
|
||||
isArchived: true,
|
||||
@@ -210,6 +224,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId);
|
||||
const messagesBySession = useAppStore((state) => state.messages.bySession);
|
||||
const archivedState = useArchivedTaskState();
|
||||
|
||||
const selectedTaskId = useMemo(() => {
|
||||
@@ -253,6 +268,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
envIdBySessionId,
|
||||
repositorySlugById,
|
||||
taskPRsByTaskId,
|
||||
messagesBySession,
|
||||
titleById,
|
||||
workflowNameById,
|
||||
stepTitleById,
|
||||
@@ -276,6 +292,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
taskPRsByTaskId,
|
||||
messagesBySession,
|
||||
archivedState,
|
||||
]);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export type TaskSwitcherItem = {
|
||||
createdAt?: string;
|
||||
isArchived?: boolean;
|
||||
primarySessionId?: string | null;
|
||||
hasPendingClarification?: boolean;
|
||||
parentTaskTitle?: string;
|
||||
parentTaskId?: string;
|
||||
prInfo?: { number: number; state: string };
|
||||
@@ -180,6 +181,7 @@ function TaskRow({
|
||||
remoteExecutorName={task.remoteExecutorName}
|
||||
taskId={task.id}
|
||||
primarySessionId={task.primarySessionId ?? null}
|
||||
hasPendingClarification={task.hasPendingClarification}
|
||||
updatedAt={task.updatedAt}
|
||||
repositories={task.repositories}
|
||||
prInfo={task.prInfo}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Message, ClarificationRequestMetadata, MessageType } from "@/lib/types/http";
|
||||
import type { ToolCallMetadata } from "@/components/task/chat/types";
|
||||
import { findPendingClarification } from "@/lib/utils/pending-clarification";
|
||||
|
||||
const ACTIVITY_MESSAGE_TYPES: Set<MessageType> = new Set([
|
||||
"thinking",
|
||||
@@ -100,17 +101,6 @@ function buildSubagentChildIds(childrenByParentToolCallId: Map<string, Message[]
|
||||
return set;
|
||||
}
|
||||
|
||||
function findPendingClarification(messages: Message[]): Message | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.type === "clarification_request") {
|
||||
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
||||
if (!metadata?.status || metadata.status === "pending") return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecoveryMessage(message: Message): boolean {
|
||||
const meta = message.metadata as Record<string, unknown> | undefined;
|
||||
return meta?.recovery_actions === true;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { useTaskPendingClarification } from "./use-task-pending-clarification";
|
||||
|
||||
function message(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: "msg-1",
|
||||
session_id: "session-1",
|
||||
task_id: "task-1",
|
||||
author_type: "agent",
|
||||
content: "",
|
||||
type: "message",
|
||||
created_at: "2026-05-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper(messagesBySession: Record<string, Message[]> = {}) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<StateProvider
|
||||
initialState={{ messages: { bySession: messagesBySession, metaBySession: {} } }}
|
||||
>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTaskPendingClarification", () => {
|
||||
it("returns false when primarySessionId is null", () => {
|
||||
const { result } = renderHook(() => useTaskPendingClarification(null), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the session has no messages in store", () => {
|
||||
const { result } = renderHook(() => useTaskPendingClarification("session-1"), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the session has a pending clarification", () => {
|
||||
const { result } = renderHook(() => useTaskPendingClarification("session-1"), {
|
||||
wrapper: wrapper({
|
||||
"session-1": [
|
||||
message({
|
||||
type: "clarification_request",
|
||||
metadata: { status: "pending" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification";
|
||||
|
||||
export function useTaskPendingClarification(primarySessionId: string | null | undefined): boolean {
|
||||
return useAppStore((state) =>
|
||||
hasPendingClarificationForSession(state.messages.bySession, primarySessionId),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isValidElement, type ReactNode } from "react";
|
||||
import { IconCheck, IconMessageQuestion } from "@tabler/icons-react";
|
||||
import { getTaskStateIcon } from "./state-icons";
|
||||
|
||||
function iconType(node: ReactNode) {
|
||||
if (!isValidElement(node)) throw new Error("Expected React element");
|
||||
return node.type;
|
||||
}
|
||||
|
||||
describe("getTaskStateIcon", () => {
|
||||
it("uses the question icon for waiting-for-input task state", () => {
|
||||
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT"))).toBe(IconMessageQuestion);
|
||||
});
|
||||
|
||||
it("uses the question icon when there is a pending clarification", () => {
|
||||
expect(iconType(getTaskStateIcon("REVIEW", undefined, true))).toBe(IconMessageQuestion);
|
||||
});
|
||||
|
||||
it("keeps review task state as the review check without pending clarification", () => {
|
||||
expect(iconType(getTaskStateIcon("REVIEW", undefined, false))).toBe(IconCheck);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconLoader2,
|
||||
IconMessageQuestion,
|
||||
IconPlayerPause,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -22,6 +23,7 @@ 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 },
|
||||
@@ -29,7 +31,7 @@ const TASK_STATE_ICONS: Record<TaskState, IconConfig> = {
|
||||
IN_PROGRESS: { Icon: IconLoader2, className: STYLE_LOADING },
|
||||
REVIEW: { Icon: IconCheck, className: STYLE_WARNING },
|
||||
BLOCKED: { Icon: IconAlertCircle, className: STYLE_WARNING },
|
||||
WAITING_FOR_INPUT: { Icon: IconCheck, 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 },
|
||||
@@ -56,8 +58,31 @@ const DEFAULT_SESSION_ICON: IconConfig = {
|
||||
className: STYLE_MUTED,
|
||||
};
|
||||
|
||||
export function getTaskStateIcon(state?: TaskState, className?: string) {
|
||||
const config = state ? (TASK_STATE_ICONS[state] ?? DEFAULT_TASK_ICON) : DEFAULT_TASK_ICON;
|
||||
export function isWaitingForInputState(state?: TaskState): boolean {
|
||||
return state === WAITING_FOR_INPUT;
|
||||
}
|
||||
|
||||
export function shouldUseQuestionTaskIcon(
|
||||
state?: TaskState,
|
||||
hasPendingClarification = false,
|
||||
): boolean {
|
||||
return isWaitingForInputState(state) || hasPendingClarification;
|
||||
}
|
||||
|
||||
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)} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { hasPendingClarification } from "./pending-clarification";
|
||||
|
||||
function message(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: "msg-1",
|
||||
session_id: "session-1",
|
||||
task_id: "task-1",
|
||||
author_type: "agent",
|
||||
content: "",
|
||||
type: "message",
|
||||
created_at: "2026-05-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasPendingClarification", () => {
|
||||
it("detects clarification requests with pending status", () => {
|
||||
expect(
|
||||
hasPendingClarification([
|
||||
message({
|
||||
type: "clarification_request",
|
||||
metadata: { status: "pending" },
|
||||
}),
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats missing clarification status as pending", () => {
|
||||
expect(hasPendingClarification([message({ type: "clarification_request" })])).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores answered clarification requests and ordinary messages", () => {
|
||||
expect(
|
||||
hasPendingClarification([
|
||||
message({ type: "message" }),
|
||||
message({
|
||||
type: "clarification_request",
|
||||
metadata: { status: "answered" },
|
||||
}),
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats rejected and expired clarifications as not pending", () => {
|
||||
expect(
|
||||
hasPendingClarification([
|
||||
message({
|
||||
type: "clarification_request",
|
||||
metadata: { status: "rejected" },
|
||||
}),
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasPendingClarification([
|
||||
message({
|
||||
type: "clarification_request",
|
||||
metadata: { status: "expired" },
|
||||
}),
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
|
||||
|
||||
export function isPendingClarificationMessage(message: Message): boolean {
|
||||
if (message.type !== "clarification_request") return false;
|
||||
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
||||
return !metadata?.status || metadata.status === "pending";
|
||||
}
|
||||
|
||||
export function findPendingClarification(messages?: readonly Message[] | null): Message | null {
|
||||
if (!messages) return null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (isPendingClarificationMessage(messages[i])) return messages[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasPendingClarification(messages?: readonly Message[] | null): boolean {
|
||||
return findPendingClarification(messages) !== null;
|
||||
}
|
||||
|
||||
export function hasPendingClarificationForSession(
|
||||
messagesBySession: Record<string, readonly Message[] | undefined>,
|
||||
sessionId?: string | null,
|
||||
): boolean {
|
||||
if (!sessionId) return false;
|
||||
return hasPendingClarification(messagesBySession[sessionId]);
|
||||
}
|
||||
Reference in New Issue
Block a user