Files
kandev/apps/web/components/task/task-item-stats-row.tsx
T
2026-09-17 15:59:28 +01:00

286 lines
8.2 KiB
TypeScript

"use client";
import { IconClockHour4, IconMail } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Fragment, type ReactNode } from "react";
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 { cn, formatRelativeTime } from "@/lib/utils";
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
import type { TaskStatusSummaryLaunchQueue } from "@/lib/types/task-status-summary";
import {
SIDEBAR_TASK_ROW_DETAIL_KEYS,
type SidebarTaskRowDetail,
} from "@/lib/state/slices/ui/sidebar-task-row-presentation";
/** 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 repository a task belongs to. The one element of the stats row allowed to
* shrink: a long `owner/repo` truncates rather than pushing the PR number and
* badges out of the row, and `title` keeps the full name reachable on hover.
*/
function RepositoryLabel({ label }: { label?: string }) {
if (!label) return null;
return (
<span
data-testid="sidebar-task-repository"
title={label}
className="min-w-0 truncate text-muted-foreground/50"
>
{label}
</span>
);
}
function buildDetailNodes({
updatedAt,
repositoryLabel,
prInfo,
hasRelativeTime,
hasRepository,
hasPullRequestNumber,
}: {
updatedAt?: string;
repositoryLabel?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
hasRelativeTime: boolean;
hasRepository: boolean;
hasPullRequestNumber: boolean;
}): Record<SidebarTaskRowDetail, ReactNode> {
return {
relative_time:
hasRelativeTime && updatedAt ? (
<span
data-testid="sidebar-task-time"
data-time-value={updatedAt}
className="shrink-0 text-muted-foreground/50"
>
{formatRelativeTime(updatedAt)}
</span>
) : null,
repository: hasRepository ? <RepositoryLabel label={repositoryLabel} /> : null,
pull_request_number:
hasPullRequestNumber && prInfo ? (
<span className="shrink-0 text-muted-foreground/50">#{prInfo.number}</span>
) : null,
};
}
function QueuedPromptCount({ count }: { count?: number }) {
const { t } = useTranslation();
if (!count) return null;
const label = t("sidebar:queuedPromptCount", { count });
return (
<span
data-testid="sidebar-task-queued-count"
aria-label={label}
title={label}
className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground/60"
>
<IconMail className="h-3 w-3" aria-hidden="true" />
{count}
</span>
);
}
function WipQueueIndicator({ wipQueue }: { wipQueue?: WipQueueStatus }) {
const { t } = useTranslation();
if (!wipQueue) return null;
const label = t("sidebar:wipQueuePosition", {
position: wipQueue.position,
total: wipQueue.total,
step: wipQueue.destinationTitle,
});
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="sidebar-task-wip-queue"
tabIndex={0}
aria-label={label}
title={label}
className="inline-flex shrink-0 items-center text-muted-foreground/60 outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<IconClockHour4 className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
function LaunchQueueIndicator({
launchQueue,
}: {
launchQueue?: TaskStatusSummaryLaunchQueue | null;
}) {
const { t } = useTranslation();
if (!launchQueue) return null;
const label = t("task:launchQueueIndicator");
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="sidebar-task-launch-queue"
tabIndex={0}
aria-label={label}
title={label}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground/70 outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<IconClockHour4 className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("task:launchQueueLabel")}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
function PollModeIndicator({
pollMode,
config,
}: {
pollMode: SessionPollMode | null;
config: (typeof POLL_MODE_CONFIG)[SessionPollMode] | null;
}) {
const { t } = useTranslation();
if (!pollMode || !config) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("font-mono text-[10px] font-semibold", config.color)}>
{config.letter}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("task:gitPollMode", { mode: pollMode, label: t(config.labelKey) })}
</TooltipContent>
</Tooltip>
);
}
function hasStatsToRender({
hasRelativeTime,
hasRepository,
hasPullRequestNumber,
pollMode,
queuedCount,
wipQueue,
launchQueue,
}: {
hasRelativeTime: boolean;
hasRepository: boolean;
hasPullRequestNumber: boolean;
pollMode: SessionPollMode | null;
queuedCount?: number;
wipQueue?: WipQueueStatus;
launchQueue?: TaskStatusSummaryLaunchQueue | null;
}): boolean {
return Boolean(
hasRelativeTime ||
hasRepository ||
hasPullRequestNumber ||
pollMode ||
queuedCount ||
wipQueue ||
launchQueue,
);
}
/**
* The sidebar row's metadata line: relative last-update time, repository, PR number,
* queued-prompt mail badge, WIP queue icon, and (debug UI only) the session
* poll-mode letter.
*/
export function TaskItemStatsRow({
updatedAt,
repositoryLabel,
prInfo,
primarySessionId,
queuedCount,
wipQueue,
launchQueue,
detailOrder = [...SIDEBAR_TASK_ROW_DETAIL_KEYS],
showRelativeTime = true,
showRepository = true,
showPullRequestNumber = true,
}: {
updatedAt?: string;
/** Repository the task belongs to; already resolved to a slug or name. */
repositoryLabel?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
primarySessionId?: string | null;
queuedCount?: number;
wipQueue?: WipQueueStatus;
launchQueue?: TaskStatusSummaryLaunchQueue | null;
detailOrder?: SidebarTaskRowDetail[];
showRelativeTime?: boolean;
showRepository?: boolean;
showPullRequestNumber?: boolean;
}) {
const pollMode = useAppStore((s) =>
isDebugUI() && primarySessionId
? (s.sessionPollMode.bySessionId[primarySessionId] ?? null)
: null,
);
const hasRelativeTime = showRelativeTime && !!updatedAt;
const hasRepository = showRepository && !!repositoryLabel;
const hasPullRequestNumber = showPullRequestNumber && !!prInfo;
if (
!hasStatsToRender({
hasRelativeTime,
hasRepository,
hasPullRequestNumber,
pollMode,
queuedCount,
wipQueue,
launchQueue,
})
) {
return null;
}
const detailNodes = buildDetailNodes({
updatedAt,
repositoryLabel,
prInfo,
hasRelativeTime,
hasRepository,
hasPullRequestNumber,
});
return (
<span className="flex min-w-0 items-center gap-1.5 text-[11px]">
{detailOrder.map((detail) =>
detailNodes[detail] ? <Fragment key={detail}>{detailNodes[detail]}</Fragment> : null,
)}
<QueuedPromptCount count={queuedCount} />
<WipQueueIndicator wipQueue={wipQueue} />
<LaunchQueueIndicator launchQueue={launchQueue} />
<PollModeIndicator
pollMode={pollMode}
config={pollMode ? POLL_MODE_CONFIG[pollMode] : null}
/>
</span>
);
}