diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 325c23b7de..e2427caa98 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -10,7 +10,10 @@ 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 { + hasPendingClarificationForSession, + hasPendingPermissionForSession, +} from "@/lib/utils/pending-clarification"; import type { TaskState, TaskSessionState, @@ -128,6 +131,10 @@ export function useSheetData(workspaceId: string | null, workflowId: string | nu messagesBySession, task.primarySessionId, ), + hasPendingPermission: hasPendingPermissionForSession( + messagesBySession, + task.primarySessionId, + ), }; }); }, [ diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx index 5b1264da2b..a648d44ff8 100644 --- a/apps/web/components/task/task-item.test.tsx +++ b/apps/web/components/task/task-item.test.tsx @@ -6,6 +6,7 @@ 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"; +const PENDING_PERMISSION_ICON_TEST_ID = "task-state-pending-permission"; afterEach(() => cleanup()); @@ -39,6 +40,25 @@ describe("TaskItem status icon", () => { expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull(); }); + it("shows alert icon when a permission request is pending", () => { + renderTaskItem({ sessionState: "WAITING_FOR_INPUT", hasPendingPermission: true }); + + expect(screen.queryByTestId(PENDING_PERMISSION_ICON_TEST_ID)).not.toBeNull(); + expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).toBeNull(); + }); + + it("prefers clarification icon over permission icon when both are pending", () => { + renderTaskItem({ + sessionState: "WAITING_FOR_INPUT", + hasPendingClarification: true, + hasPendingPermission: true, + }); + + expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).not.toBeNull(); + expect(screen.queryByTestId(PENDING_PERMISSION_ICON_TEST_ID)).toBeNull(); + }); + it("keeps the review check for completed review tasks", () => { renderTaskItem({ sessionState: "COMPLETED" }); diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 7340c8743a..4faaea4c7c 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -9,6 +9,7 @@ import { IconGitPullRequest, IconMessageQuestion, IconPinFilled, + IconShieldQuestion, } from "@tabler/icons-react"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { IssueTaskIcon } from "@/components/github/issue-task-icon"; @@ -18,7 +19,7 @@ import { DEBUG_UI } from "@/lib/config"; import { useTaskColor } from "@/hooks/use-task-color"; import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; -import { shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; +import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } 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"; @@ -47,6 +48,7 @@ type TaskItemProps = { taskId?: string; primarySessionId?: string | null; hasPendingClarification?: boolean; + hasPendingPermission?: boolean; parentTaskTitle?: string; isSubTask?: boolean; /** Number of subtasks under this parent task. Only set for parent rows. */ @@ -97,11 +99,13 @@ function TaskStateIcon({ state, isInProgress, hasPendingClarification, + hasPendingPermission, }: { sessionState?: TaskSessionState; state?: TaskState; isInProgress: boolean; hasPendingClarification?: boolean; + hasPendingPermission?: boolean; }) { if (shouldUseQuestionTaskIcon(state, hasPendingClarification)) { return ( @@ -111,6 +115,14 @@ function TaskStateIcon({ /> ); } + if (shouldUsePermissionTaskIcon(hasPendingPermission)) { + return ( + + ); + } if (isInProgress) { return ( ): SidebarI remoteExecutorName: undefined, primarySessionId: null, hasPendingClarification: false, + hasPendingPermission: false, updatedAt: s.archivedTaskUpdatedAt, createdAt: undefined, isArchived: true, diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 837463508e..b0c75b83a9 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -47,6 +47,7 @@ export type TaskSwitcherItem = { isArchived?: boolean; primarySessionId?: string | null; hasPendingClarification?: boolean; + hasPendingPermission?: boolean; parentTaskTitle?: string; parentTaskId?: string; prInfo?: { number: number; state: string }; @@ -195,6 +196,7 @@ function TaskRow({ taskId={task.id} primarySessionId={task.primarySessionId ?? null} hasPendingClarification={task.hasPendingClarification} + hasPendingPermission={task.hasPendingPermission} updatedAt={task.updatedAt} repositories={task.repositories} prInfo={task.prInfo} diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx index c2994b6828..7740c3ce31 100644 --- a/apps/web/lib/ui/state-icons.tsx +++ b/apps/web/lib/ui/state-icons.tsx @@ -69,6 +69,10 @@ export function shouldUseQuestionTaskIcon( return isWaitingForInputState(state) || hasPendingClarification; } +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; diff --git a/apps/web/lib/utils/pending-clarification.test.ts b/apps/web/lib/utils/pending-clarification.test.ts index 95b180cfd5..6e5cccef48 100644 --- a/apps/web/lib/utils/pending-clarification.test.ts +++ b/apps/web/lib/utils/pending-clarification.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Message } from "@/lib/types/http"; -import { findPendingClarificationGroup, hasPendingClarification } from "./pending-clarification"; +import { + findPendingClarificationGroup, + hasPendingClarification, + hasPendingPermissionRequest, +} from "./pending-clarification"; function message(overrides: Partial): Message { return { @@ -132,3 +136,51 @@ describe("findPendingClarificationGroup", () => { expect(findPendingClarificationGroup([old, a]).map((m) => m.id)).toEqual(["a"]); }); }); + +describe("hasPendingPermissionRequest", () => { + it("detects permission requests with pending status", () => { + expect( + hasPendingPermissionRequest([ + message({ type: "permission_request", metadata: { status: "pending" } }), + ]), + ).toBe(true); + }); + + it("treats missing permission status as pending", () => { + expect(hasPendingPermissionRequest([message({ type: "permission_request" })])).toBe(true); + }); + + it("ignores approved permission requests", () => { + expect( + hasPendingPermissionRequest([ + message({ type: "permission_request", metadata: { status: "approved" } }), + ]), + ).toBe(false); + }); + + it("ignores rejected permission requests", () => { + expect( + hasPendingPermissionRequest([ + message({ type: "permission_request", metadata: { status: "rejected" } }), + ]), + ).toBe(false); + }); + + it("ignores expired permission requests", () => { + expect( + hasPendingPermissionRequest([ + message({ type: "permission_request", metadata: { status: "expired" } }), + ]), + ).toBe(false); + }); + + it("returns false for non-permission messages", () => { + expect(hasPendingPermissionRequest([message({ type: "message" })])).toBe(false); + }); + + it("returns false for empty or null input", () => { + expect(hasPendingPermissionRequest([])).toBe(false); + expect(hasPendingPermissionRequest(null)).toBe(false); + expect(hasPendingPermissionRequest(undefined)).toBe(false); + }); +}); diff --git a/apps/web/lib/utils/pending-clarification.ts b/apps/web/lib/utils/pending-clarification.ts index 129af13fad..b0c4869b2f 100644 --- a/apps/web/lib/utils/pending-clarification.ts +++ b/apps/web/lib/utils/pending-clarification.ts @@ -54,3 +54,27 @@ export function hasPendingClarificationForSession( if (!sessionId) return false; return hasPendingClarification(messagesBySession[sessionId]); } + +// --- Permission request helpers --- + +export function isPendingPermissionMessage(message: Message): boolean { + if (message.type !== "permission_request") return false; + const metadata = message.metadata as { status?: string } | undefined; + return !metadata?.status || metadata.status === "pending"; +} + +export function hasPendingPermissionRequest(messages?: readonly Message[] | null): boolean { + if (!messages) return false; + for (let i = messages.length - 1; i >= 0; i--) { + if (isPendingPermissionMessage(messages[i])) return true; + } + return false; +} + +export function hasPendingPermissionForSession( + messagesBySession: Record, + sessionId?: string | null, +): boolean { + if (!sessionId) return false; + return hasPendingPermissionRequest(messagesBySession[sessionId]); +}