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:
José Almeida
2026-05-02 23:01:21 +01:00
committed by GitHub
parent eee547d891
commit 32f098a6fc
15 changed files with 327 additions and 22 deletions
+7 -3
View File
@@ -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();
});
});
+20 -1
View File
@@ -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 -11
View File
@@ -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),
);
}
+23
View File
@@ -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);
});
});
+28 -3
View File
@@ -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]);
}