feat: show queued prompt count badge in the task sidebar (#2354)

This commit is contained in:
Clément Fiere
2026-08-07 16:34:39 +02:00
committed by GitHub
parent 4d80edacf2
commit 084ecc8b54
51 changed files with 2262 additions and 64 deletions
@@ -134,3 +134,21 @@ describe("toSheetItem", () => {
).toBe(ERROR_PREVIEW);
});
});
describe("toSheetItem queued prompt count", () => {
it("carries the queued prompt count from the task status summary", () => {
const item = toSheetItem(
task({ statusSummary: { revision: 3, updated_at: UPDATED_AT, queued_prompt_count: 2 } }),
emptyCtx(),
);
expect(item.queuedCount).toBe(2);
});
it("leaves queuedCount undefined when nothing is queued", () => {
const item = toSheetItem(
task({ statusSummary: { revision: 4, updated_at: UPDATED_AT } }),
emptyCtx(),
);
expect(item.queuedCount).toBeUndefined();
});
});
@@ -126,6 +126,9 @@ export function toSheetItem(
isRemoteExecutor: task.isRemoteExecutor,
remoteExecutorType: task.primaryExecutorType ?? undefined,
remoteExecutorName: task.primaryExecutorName ?? undefined,
// Queued prompt count badge — same status summary source as the desktop
// sidebar mapper (buildSidebarItem) so both surfaces agree.
queuedCount: task.statusSummary?.queued_prompt_count,
};
}
@@ -1,31 +1,42 @@
"use client";
import { useAppStore } from "@/components/state-provider";
import { IconMail } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn, formatRelativeTime } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { isDebugUI } from "@/lib/config";
import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types";
import { useTranslation } from "react-i18next";
import { cn, formatRelativeTime } from "@/lib/utils";
/** Debug-overlay-only poll-mode legend. `label` stays English on purpose: the
* row is gated behind `isDebugUI()`, so these are developer diagnostics rather
* than user copy — and a SCREAMING_CASE const is invisible to
* `i18next/no-literal-string`, so nothing would have flagged it either way. */
const POLL_MODE_CONFIG: Record<SessionPollMode, { letter: string; color: string; label: string }> =
{
fast: { letter: "F", color: "text-emerald-500", label: "focused, 2s polling" },
slow: { letter: "S", color: "text-yellow-500", label: "subscribed, 30s polling" },
paused: { letter: "P", color: "text-muted-foreground/40", label: "no subscribers" },
};
/** Debug-overlay-only poll-mode legend. Labels resolve through `t()` so the
* pseudo-locale stays complete; the row itself is gated behind `isDebugUI()`. */
const POLL_MODE_CONFIG: Record<
SessionPollMode,
{ letter: string; color: string; labelKey: string }
> = {
fast: { letter: "F", color: "text-emerald-500", labelKey: "sidebar:pollModeFastLabel" },
slow: { letter: "S", color: "text-yellow-500", labelKey: "sidebar:pollModeSlowLabel" },
paused: {
letter: "P",
color: "text-muted-foreground/40",
labelKey: "sidebar:pollModePausedLabel",
},
};
/**
* The sidebar row's metadata line: relative last-update time, PR number,
* queued-prompt mail badge, and (debug UI only) the session poll-mode letter.
*/
export function TaskItemStatsRow({
updatedAt,
prInfo,
primarySessionId,
queuedCount,
}: {
updatedAt?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
primarySessionId?: string | null;
queuedCount?: number;
}) {
const { t } = useTranslation();
const pollMode = useAppStore((s) =>
@@ -34,9 +45,10 @@ export function TaskItemStatsRow({
: null,
);
if (!updatedAt && !prInfo && !pollMode) return null;
if (!updatedAt && !prInfo && !pollMode && !queuedCount) return null;
const modeConfig = pollMode ? POLL_MODE_CONFIG[pollMode] : null;
const modeLabel = modeConfig ? t(modeConfig.labelKey) : "";
return (
<span className="flex items-center gap-1.5 text-[11px]">
@@ -44,6 +56,17 @@ export function TaskItemStatsRow({
<span className="text-muted-foreground/50">{formatRelativeTime(updatedAt)}</span>
)}
{prInfo && <span className="text-muted-foreground/50">#{prInfo.number}</span>}
{queuedCount ? (
<span
data-testid="sidebar-task-queued-count"
aria-label={t("sidebar:queuedPromptCount", { count: queuedCount })}
title={t("sidebar:queuedPromptCount", { count: queuedCount })}
className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground/60"
>
<IconMail className="h-3 w-3" aria-hidden="true" />
{queuedCount}
</span>
) : null}
{modeConfig && (
<Tooltip>
<TooltipTrigger asChild>
@@ -52,7 +75,7 @@ export function TaskItemStatsRow({
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("task:gitPollMode", { mode: pollMode, label: modeConfig.label })}
{t("task:gitPollMode", { mode: pollMode, label: modeLabel })}
</TooltipContent>
</Tooltip>
)}
@@ -391,3 +391,38 @@ describe("TaskItem background-running lifecycle", () => {
expect(screen.queryByTestId(RUNNING_ICON_TEST_ID)).toBeNull();
});
});
describe("TaskItem queued prompt count badge", () => {
const QUEUED_BADGE_TEST_ID = "sidebar-task-queued-count";
it("shows a mail badge with the count when prompts are queued", () => {
renderTaskItem({ queuedCount: 3 });
const badge = screen.getByTestId(QUEUED_BADGE_TEST_ID);
expect(badge.textContent).toBe("3");
expect(badge.querySelector("svg")).not.toBeNull();
});
it("hides the badge when nothing is queued", () => {
renderTaskItem({ queuedCount: 0 });
expect(screen.queryByTestId(QUEUED_BADGE_TEST_ID)).toBeNull();
renderTaskItem({ queuedCount: undefined });
expect(screen.queryByTestId(QUEUED_BADGE_TEST_ID)).toBeNull();
});
it("renders the metadata line for a row whose only metadata is the queued count", () => {
renderTaskItem({ queuedCount: 2, updatedAt: undefined });
const badge = screen.getByTestId(QUEUED_BADGE_TEST_ID);
expect(badge.textContent).toBe("2");
});
it("localizes the accessible label with the count", () => {
renderTaskItem({ queuedCount: 1 });
expect(screen.getByTestId(QUEUED_BADGE_TEST_ID).getAttribute("aria-label")).toContain(
"queued prompt",
);
});
});
+12 -1
View File
@@ -85,6 +85,8 @@ type TaskItemProps = {
onToggleSubtasks?: () => void;
repositories?: string[];
prInfo?: { number: number; state: string; aggregateState?: string };
/** Number of prompts currently en-queued for this task (mail badge). */
queuedCount?: number;
issueInfo?: { url: string; number: number };
isPinned?: boolean;
agentErrorMessage?: string | null;
@@ -324,6 +326,7 @@ function TaskItemContent({
repositories,
updatedAt,
prInfo,
queuedCount,
issueInfo,
agentErrorMessage,
}: {
@@ -338,6 +341,7 @@ function TaskItemContent({
repositories?: string[];
updatedAt?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
queuedCount?: number;
issueInfo?: { url: string; number: number };
agentErrorMessage?: string | null;
}) {
@@ -375,7 +379,12 @@ function TaskItemContent({
{repositories.join(" · ")}
</span>
)}
<TaskItemStatsRow updatedAt={updatedAt} prInfo={prInfo} primarySessionId={primarySessionId} />
<TaskItemStatsRow
updatedAt={updatedAt}
prInfo={prInfo}
primarySessionId={primarySessionId}
queuedCount={queuedCount}
/>
</div>
);
}
@@ -428,6 +437,7 @@ export const TaskItem = memo(function TaskItem({
onToggleSubtasks,
repositories,
prInfo,
queuedCount,
issueInfo,
isPinned,
agentErrorMessage,
@@ -474,6 +484,7 @@ export const TaskItem = memo(function TaskItem({
repositories={repositories}
updatedAt={updatedAt}
prInfo={prInfo}
queuedCount={queuedCount}
issueInfo={issueInfo}
agentErrorMessage={agentErrorMessage}
/>
@@ -92,4 +92,22 @@ describe("buildSidebarItem", () => {
expect(item.isArchived).toBe(true);
});
it("carries the queued prompt count from the status summary", () => {
const item = buildSidebarItem(
task({ statusSummary: { revision: 4, updated_at: UPDATED_AT, queued_prompt_count: 3 } }),
emptyContext(),
);
expect(item.queuedCount).toBe(3);
});
it("leaves queuedCount undefined when the summary has no queued prompts", () => {
const item = buildSidebarItem(
task({ statusSummary: { revision: 5, updated_at: UPDATED_AT } }),
emptyContext(),
);
expect(item.queuedCount).toBeUndefined();
});
});
@@ -104,6 +104,7 @@ function sidebarStatus(
hasPendingPermission: pending.permission,
prInfo: summaryPRInfo(summary),
issueInfo: issueInfoForTask(task),
queuedCount: summary?.queued_prompt_count,
};
}
@@ -154,6 +154,7 @@ function TaskRowItem({
updatedAt={task.updatedAt}
repositories={task.repositories}
prInfo={task.prInfo}
queuedCount={task.queuedCount}
issueInfo={task.issueInfo}
agentErrorMessage={task.agentErrorMessage}
isSubTask={isSubTask}
@@ -39,6 +39,8 @@ export type TaskSwitcherItem = {
parentTaskId?: string;
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group";
prInfo?: { number: number; state: string; aggregateState?: string };
/** Number of prompts currently en-queued for this task (mail badge). */
queuedCount?: number;
isPRReview?: boolean;
isIssueWatch?: boolean;
issueInfo?: { url: string; number: number };