"use client"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { AgentLogo } from "@/components/agent-logo"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { useSessionActions, isSessionStoppable, isSessionDeletable, isSessionResumable, } from "@/hooks/domains/session/use-session-actions"; import { HandoffDropdownMenuSub } from "../handoff-profile-menu-items"; import { NewSessionDialog, type HandoffPreset } from "../new-session-dialog"; import { MobilePillButton } from "./mobile-pill-button"; import { MobilePickerSheet } from "./mobile-picker-sheet"; import { formatTaskSessionStateLabel } from "@/lib/ui/state-labels"; import { getSessionStateIcon } from "@/lib/ui/state-icons"; import { repositorySlug } from "@/lib/repository-slug"; import { useSessionPendingInput, type PendingInput } from "@/hooks/use-task-pending-input"; import type { ForegroundActivity, TaskSession, TaskSessionState } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; import { MobileSessionDeleteConfirmation } from "./mobile-session-delete-confirmation"; type SessionRow = { id: string; agentName: string | null; agentLabel: string; repositoryLabel: string | null; state: TaskSessionState | null; foregroundActivity: ForegroundActivity | null; /** True when the session is waiting on the operator to notice, not to act — * a settled session with a positively-sampled background process still * live (spec: docs/specs/disambiguate-waiting/spec.md). */ parkedOnBackgroundWork: boolean; isPrimary: boolean; index: number; startedAt: string; }; function buildSessionRows( sessions: TaskSession[], agentProfiles: AgentProfileOption[], primarySessionId: string | null | undefined, repositoryLabelsById: ReadonlyMap, ): SessionRow[] { const sorted = [...sessions].sort( (a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), ); return sorted.map((s, idx) => { const profile = agentProfiles.find((p) => p.id === s.agent_profile_id); const labelParts = profile?.label.split(" • ") ?? []; return { id: s.id, agentName: profile?.agent_name ?? null, // User-supplied session name wins over the derived profile label, // matching the desktop session tab title precedence. agentLabel: s.name || labelParts[1] || labelParts[0] || profile?.label || t("task:agent"), repositoryLabel: s.repository_id ? (repositoryLabelsById.get(s.repository_id) ?? null) : null, state: (s.state as TaskSessionState | undefined) ?? null, foregroundActivity: s.foreground_activity ?? null, parkedOnBackgroundWork: !!s.parked_on_background_work, isPrimary: primarySessionId ? s.id === primarySessionId : !!s.is_primary, index: idx + 1, startedAt: s.started_at, }; }); } // The mobile sessions section reads the per-session substate through the same // shared vocabulary as the desktop session switcher and the reopen menu // `getSessionStateIcon` carries background-running // as a spinner distinct — by SHAPE and MOTION, not hue alone — from generating // (a static dot) and from done (a check), so the three read apart even in a // grayscale scan. The label reinforces the distinction in words: while spawned // background work runs, the row reads "Working in background" rather than the // bare "Running", so background is never confused with generating. // A catalog key, not copy: `t()` at module scope would freeze at the boot locale. const BACKGROUND_RUNNING_LABEL_KEY = "task:workingInBackground"; function sessionStateLabel( state: TaskSessionState, foregroundActivity: ForegroundActivity | null, pending: PendingInput, parkedOnBackgroundWork: boolean, ): string { const canRequestInput = state === "RUNNING" || state === "WAITING_FOR_INPUT"; if (canRequestInput && pending.permission) return t("task:permissionRequested"); if (canRequestInput && pending.clarification) { return formatTaskSessionStateLabel("WAITING_FOR_INPUT"); } if (canRequestInput && foregroundActivity === "background") { return t(BACKGROUND_RUNNING_LABEL_KEY); } if (canRequestInput && parkedOnBackgroundWork) { return t(BACKGROUND_RUNNING_LABEL_KEY); } return formatTaskSessionStateLabel(state); } function StateBadge({ sessionId, state, foregroundActivity, parkedOnBackgroundWork, testId, }: { sessionId: string; state: TaskSessionState | null; foregroundActivity: ForegroundActivity | null; parkedOnBackgroundWork: boolean; testId?: string; }) { const pending = useSessionPendingInput(sessionId); if (!state) return null; const label = sessionStateLabel(state, foregroundActivity, pending, parkedOnBackgroundWork); return ( {getSessionStateIcon(state, "h-3 w-3 shrink-0", { foregroundActivity, hasPendingClarification: pending.clarification, hasPendingPermission: pending.permission, parkedOnBackgroundWork, })} {label} ); } function SessionActionsMenu({ triggerRef, taskId, state, isPrimary, onSetPrimary, onStop, onResume, onAskDelete, onHandoffProfile, }: { triggerRef: RefObject; taskId: string; state: TaskSessionState | null; isPrimary: boolean; onSetPrimary: () => void; onStop: () => void; onResume: () => void; onAskDelete: () => void; onHandoffProfile: (profileId: string) => void; }) { const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const pendingDelete = useRef(false); const hasLifecycleAction = !!state && (isSessionStoppable(state) || isSessionResumable(state) || isSessionDeletable(state)); return ( e.stopPropagation()} onCloseAutoFocus={(event) => { if (!pendingDelete.current) return; event.preventDefault(); pendingDelete.current = false; onAskDelete(); }} > {t("task:setAsPrimary")} {state && isSessionStoppable(state) && ( {t("task:stop")} )} {state && isSessionResumable(state) && ( {t("task:resume")} )} {state && isSessionDeletable(state) && ( { if (isMobile) pendingDelete.current = true; else onAskDelete(); }} > {t("task:delete")} )} {hasLifecycleAction && } ); } function SessionIdentity({ row }: { row: SessionRow }) { return (
{row.agentLabel} {row.repositoryLabel && ( {row.repositoryLabel} )}
); } function SessionRowTrailingActions({ row, taskId, totalSessions, isConfirming, onAskDelete, onCancelDelete, onHandoffProfile, actions, }: { row: SessionRow; taskId: string; totalSessions: number; isConfirming: boolean; onAskDelete: () => void; onCancelDelete: () => void; onHandoffProfile: (profileId: string) => void; actions: ReturnType; }) { const { isMobile } = useResponsiveBreakpoint(); const triggerRef = useRef(null); return ( <> void actions.remove()} /> {(!isConfirming || isMobile) && ( void actions.setPrimary()} onStop={() => void actions.stop()} onResume={() => void actions.resume()} onAskDelete={onAskDelete} onHandoffProfile={onHandoffProfile} /> )} ); } function SessionRowItem({ row, taskId, isActive, totalSessions, isConfirming, onAskDelete, onCancelDelete, onSelect, }: { row: SessionRow; taskId: string; isActive: boolean; totalSessions: number; isConfirming: boolean; onAskDelete: () => void; onCancelDelete: () => void; onSelect: (sessionId: string) => void; }) { const [handoffOpen, setHandoffOpen] = useState(false); const [handoffPreset, setHandoffPreset] = useState(null); const actions = useSessionActions({ sessionId: row.id, taskId }); const showBadges = totalSessions > 1; const handleHandoffProfile = useCallback( (profileId: string) => { setHandoffPreset({ sourceSessionId: row.id, targetProfileId: profileId }); setHandoffOpen(true); }, [row.id], ); return ( <>
onSelect(row.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(row.id); } }} data-testid={`mobile-session-row-${row.id}`} className={`flex items-center gap-2 border px-2 py-2 rounded-md cursor-pointer select-none ${ isActive ? "border-primary/50 bg-card" : "border-transparent hover:bg-muted" }`} > {row.isPrimary && showBadges && ( )} {showBadges && ( {row.index} )} {row.agentName && }
{handoffPreset && ( { setHandoffOpen(open); if (!open) setHandoffPreset(null); }} taskId={taskId} handoff={handoffPreset} /> )} ); } function useSessionRows(taskId: string | null) { // `buildSessionRows` resolves its agent-name fallback through the module-level // `t`, so the language has to reach the memo below for that label to follow a // runtime locale switch. const { i18n } = useTranslation(); const agentProfiles = useAppStore((s) => s.agentProfiles.items); const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const primarySessionId = useAppStore((s) => { if (!taskId) return null; const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); return task?.primarySessionId ?? null; }); const { sessions, isLoading } = useTaskSessions(taskId); const repositoryLabelsById = useMemo(() => { const sessionRepositoryIds = new Set( sessions.flatMap((session) => (session.repository_id ? [session.repository_id] : [])), ); if (sessionRepositoryIds.size <= 1) return new Map(); const labels = new Map(); for (const repository of Object.values(repositoriesByWorkspace).flat()) { if (sessionRepositoryIds.has(repository.id)) { labels.set(repository.id, repositorySlug(repository)); } } return labels; }, [repositoriesByWorkspace, sessions]); const rows = useMemo( () => buildSessionRows(sessions, agentProfiles, primarySessionId, repositoryLabelsById), // eslint-disable-next-line react-hooks/exhaustive-deps -- i18n.language is the locale trigger [sessions, agentProfiles, primarySessionId, repositoryLabelsById, i18n.language], ); return { rows, isLoading, primarySessionId }; } const MobileSessionsList = memo(function MobileSessionsList({ taskId, activeSessionId, open, onClose, }: { taskId: string | null; activeSessionId: string | null; open: boolean; onClose: () => void; }) { const { t } = useTranslation(); const setActiveSession = useAppStore((s) => s.setActiveSession); const { rows, isLoading } = useSessionRows(taskId); const [launchOpen, setLaunchOpen] = useState(false); const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); useEffect(() => { if (!open) setConfirmDeleteSessionId(null); }, [open]); const handleSelect = useCallback( (sessionId: string) => { if (!taskId) return; setConfirmDeleteSessionId(null); setActiveSession(taskId, sessionId); onClose(); }, [taskId, setActiveSession, onClose], ); if (!taskId) { return (
{t("task:noActiveTask")}
); } return (
{t("task:sessionCount", { count: rows.length })}
{isLoading && rows.length === 0 && (
{t("task:loadingSessions")}
)} {!isLoading && rows.length === 0 && (
{t("task:noSessionsYetLaunchOneTo")}
)} {rows.map((row) => ( setConfirmDeleteSessionId(row.id)} onCancelDelete={() => setConfirmDeleteSessionId(null)} onSelect={handleSelect} /> ))}
{launchOpen && ( )}
); }); function useActiveSessionPillLabel( taskId: string | null, sessionId: string | null | undefined, ): { label: string; count: string | undefined; agentName: string | null; effectiveSessionId: string | null; ariaLabel: string; } { const { t } = useTranslation(); const storedActiveSessionId = useAppStore((s) => s.tasks.activeSessionId); const { rows } = useSessionRows(taskId); const effectiveSessionId = sessionId === undefined ? storedActiveSessionId : sessionId; const activeRow = rows.find((r) => r.id === effectiveSessionId); const total = rows.length; const idx = activeRow?.index; let count: string | undefined; if (total > 1 && idx) count = `${idx}/${total}`; else if (total > 1) count = `${total}`; const agentLabel = activeRow?.agentLabel ?? t("task:sessionFallbackLabel"); const repositoryLabel = activeRow?.repositoryLabel; return { label: repositoryLabel ? `${agentLabel} · ${repositoryLabel}` : agentLabel, count, agentName: activeRow?.agentName ?? null, effectiveSessionId, ariaLabel: repositoryLabel ? t("task:activeSessionRepositoryTapToSwitch", { agentLabel, repositoryLabel }) : t("task:activeSessionTapToSwitch", { agentLabel }), }; } export const MobileSessionsPicker = memo(function MobileSessionsPicker({ taskId, sessionId, compact, fullWidth, }: { taskId: string | null; sessionId?: string | null; compact?: boolean; fullWidth?: boolean; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const { label, count, agentName, effectiveSessionId, ariaLabel } = useActiveSessionPillLabel( taskId, sessionId, ); if (!taskId) return null; return ( <> ) : undefined } label={label} count={count} compact={compact} fullWidth={fullWidth} isOpen={open} onClick={() => setOpen(true)} data-testid="mobile-sessions-pill" ariaLabel={ariaLabel} /> setOpen(false)} /> ); });