"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { IconStar } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; import { GridSpinner } from "@/components/grid-spinner"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@kandev/ui/alert-dialog"; import { useAppStore } from "@/components/state-provider"; import { useSessionActions, isSessionStoppable as isStoppable, isSessionDeletable as isDeletable, isSessionResumable as isResumable, } from "@/hooks/domains/session/use-session-actions"; import { shareableSessionStateClient } from "@/components/task/share/share-button"; import { ShareDialog } from "@/components/task/share/share-dialog"; import type { TaskSessionState } from "@/lib/types/http"; import { isSessionActive } from "./session-sort"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; function useSessionTabState(sessionId: string | undefined) { const isPrimary = useAppStore((state) => { const activeTaskId = state.tasks.activeTaskId; if (!activeTaskId || !sessionId) return false; const task = state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId); if (task?.primarySessionId) return task.primarySessionId === sessionId; return state.taskSessions.items[sessionId]?.is_primary === true; }); const sessionState = useAppStore((state) => { if (!sessionId) return null; return state.taskSessions.items[sessionId]?.state ?? null; }) as TaskSessionState | null; const taskId = useAppStore((state) => state.tasks.activeTaskId); const agentLabel = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; if (!session?.agent_profile_id) return null; const profile = state.agentProfiles.items.find( (p: { id: string }) => p.id === session.agent_profile_id, ); if (!profile) return null; const parts = profile.label.split(" \u2022 "); return parts[1] || parts[0] || profile.label; }); const agentName = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; if (!session?.agent_profile_id) return null; return ( state.agentProfiles.items.find((p: { id: string }) => p.id === session.agent_profile_id) ?.agent_name ?? null ); }); const sessionNumber = useAppStore((state) => { if (!sessionId) return null; const activeTaskId = state.tasks.activeTaskId; const sessions = activeTaskId ? state.taskSessionsByTask.itemsByTaskId[activeTaskId] : null; if (!sessions) return null; // Sort chronologically (oldest first) so indexes are stable regardless of // which session is primary or the backend's default DESC ordering. const sorted = [...sessions].sort( (a: { started_at: string }, b: { started_at: string }) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), ); const idx = sorted.findIndex((s: { id: string }) => s.id === sessionId); return idx >= 0 ? idx + 1 : null; }); const sessionCount = useAppStore((state) => { const activeTaskId = state.tasks.activeTaskId; if (!activeTaskId) return 0; return state.taskSessionsByTask.itemsByTaskId[activeTaskId]?.length ?? 0; }); return { isPrimary, sessionState, taskId, agentLabel, agentName, sessionNumber, sessionCount }; } function useSessionTabActions( sessionId: string | undefined, taskId: string | null, api: IDockviewPanelHeaderProps["api"], containerApi: IDockviewPanelHeaderProps["containerApi"], ) { const onDeleted = useCallback(() => { const panel = containerApi.getPanel(api.id); if (panel) containerApi.removePanel(panel); }, [api.id, containerApi]); const { setPrimary: handleSetPrimary, stop: handleStop, resume: handleResume, remove: handleDelete, } = useSessionActions({ sessionId, taskId, onDeleted }); const handleCloseOthers = useCallback(() => { const toClose = api.group.panels.filter((p) => p.id !== api.id); for (const panel of toClose) containerApi.removePanel(panel); }, [api, containerApi]); return { handleSetPrimary, handleStop, handleResume, handleDelete, handleCloseOthers }; } function DeleteSessionDialog({ open, onOpenChange, isPrimary, sessionCount, onConfirm, }: { open: boolean; onOpenChange: (open: boolean) => void; isPrimary: boolean; sessionCount: number; onConfirm: () => void; }) { return ( Delete session? This will permanently delete the conversation history with this session. {isPrimary && sessionCount > 1 && ( This is the primary session. Another session will be set as primary. )} {sessionCount === 1 && ( This is the only session for this task. )} Cancel { onOpenChange(false); onConfirm(); }} className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete ); } function SessionContextMenuItems({ sessionState, isPrimary, canShare, actions, onDelete, onShare, }: { sessionState: TaskSessionState | null; isPrimary: boolean; canShare: boolean; actions: ReturnType; onDelete: () => void; onShare: () => void; }) { return ( Set as Primary {sessionState && isStoppable(sessionState) && ( Stop )} {sessionState && isResumable(sessionState) && ( Resume )} {sessionState && isDeletable(sessionState) && ( Delete )} {canShare && ( <> Share > )} Close Others ); } function SessionTabTriggerContent({ props, sessionId, isPrimary, showMultiSessionBadges, sessionNumber, agentName, sessionState, isActive, showDeleteOnClose, onCloseTab, }: { props: IDockviewPanelHeaderProps; sessionId: string | undefined; isPrimary: boolean; showMultiSessionBadges: boolean; sessionNumber: number | null; agentName: string | null; sessionState: TaskSessionState | null; isActive: boolean; showDeleteOnClose: boolean; onCloseTab: () => void; }) { const tabContentRef = useRef(null); useEffect(() => { if (!showDeleteOnClose || !sessionId) return; const closeAction = tabContentRef.current?.querySelector(".dv-default-tab-action"); if (!closeAction) return; closeAction.setAttribute("data-testid", `session-tab-close-${sessionId}`); return () => closeAction.removeAttribute("data-testid"); }, [showDeleteOnClose, sessionId, isActive]); // isActive: re-run when tab activates so Dockview renders .dv-default-tab-action return ( {isPrimary && showMultiSessionBadges && ( )} {sessionNumber != null && showMultiSessionBadges && ( {sessionNumber} )} {agentName && (isSessionActive(sessionState) ? ( ) : ( ))} ); } /** * Custom dockview tab for session panels. * Shows agent logo, index badge, and star for primary; right-click for lifecycle actions. */ export function SessionTab(props: IDockviewPanelHeaderProps) { const { api, containerApi } = props; const sessionId = api.id.startsWith("session:") ? api.id.slice("session:".length) : undefined; const { isPrimary, sessionState, taskId, agentLabel, agentName, sessionNumber, sessionCount } = useSessionTabState(sessionId); const actions = useSessionTabActions(sessionId, taskId, api, containerApi); const onDoubleClick = useTabMaximizeOnDoubleClick(api); const [confirmDelete, setConfirmDelete] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [isActive, setIsActive] = useState(api.isActive); const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); useEffect(() => { const disposable = api.onDidActiveChange((e) => setIsActive(e.isActive)); return () => disposable.dispose(); }, [api]); useEffect(() => { if (agentLabel && api.title !== agentLabel) api.setTitle(agentLabel); }, [agentLabel, api]); const showMultiSessionBadges = sessionCount > 1; // Multi-session tab close means delete, not hide-only. Running/starting sessions are // not deletable, so we omit the X rather than reviving hide-only close behavior. const showDeleteOnClose = showMultiSessionBadges && !!sessionState && isDeletable(sessionState); const handleCloseTab = useCallback(() => { setConfirmDelete(true); }, []); return ( <> setConfirmDelete(true)} onShare={() => setShareOpen(true)} /> {taskId && sessionId && ( )} > ); }
This will permanently delete the conversation history with this session.
This is the primary session. Another session will be set as primary.
This is the only session for this task.