"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 { HandoffContextMenuSub } from "@/components/task/handoff-profile-menu-items"; import { NewSessionDialog, type HandoffPreset } from "@/components/task/new-session-dialog"; import { usableConfigOptions } from "@/components/model-config-selector"; import type { TaskSessionState } from "@/lib/types/http"; import { isSessionActive } from "./session-sort"; import { resolveSessionTabTitle } from "./session-tab-title"; 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 tabTitle = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; const sessionModels = state.sessionModels.bySessionId[sessionId]; const activeModelId = state.activeModel.bySessionId[sessionId] || null; const agentLabel = (() => { 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 snapshotModel = typeof session?.agent_profile_snapshot?.model === "string" ? session.agent_profile_snapshot.model : null; return resolveSessionTabTitle({ agentLabel, activeModelId, currentModelId: sessionModels?.currentModelId || null, snapshotModel, modelOptions: sessionModels?.models.map((model) => ({ id: model.modelId, name: model.name, description: model.description, usageMultiplier: model.usageMultiplier, })) ?? [], configOptions: usableConfigOptions(sessionModels?.configOptions), }); }); 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, tabTitle, 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, taskId, sessionId, actions, onDelete, onShare, onHandoffProfile, }: { sessionState: TaskSessionState | null; isPrimary: boolean; canShare: boolean; taskId: string | null; sessionId: string | undefined; actions: ReturnType; onDelete: () => void; onShare: () => void; onHandoffProfile: (profileId: string) => void; }) { return ( Set as Primary {sessionState && isStoppable(sessionState) && ( Stop )} {sessionState && isResumable(sessionState) && ( Resume )} {sessionState && isDeletable(sessionState) && ( Delete )} {canShare && ( <> Share )} {taskId && sessionId && ( <> )} 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, tabTitle, 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 [handoffOpen, setHandoffOpen] = useState(false); const [handoffPreset, setHandoffPreset] = useState(null); const [isActive, setIsActive] = useState(api.isActive); const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); const handleHandoffProfile = useCallback( (profileId: string) => { if (!sessionId) return; setHandoffPreset({ sourceSessionId: sessionId, targetProfileId: profileId }); setHandoffOpen(true); }, [sessionId], ); useEffect(() => { const disposable = api.onDidActiveChange((e) => setIsActive(e.isActive)); return () => disposable.dispose(); }, [api]); useEffect(() => { if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle); }, [tabTitle, 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)} onHandoffProfile={handleHandoffProfile} /> {taskId && sessionId && ( )} {taskId && handoffPreset && ( { setHandoffOpen(open); if (!open) setHandoffPreset(null); }} taskId={taskId} groupId={api.group?.id} handoff={handoffPreset} /> )} ); }