feat: show queued prompt count badge in the task sidebar (#2354)
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user