"use client"; import { useCallback, useEffect, useRef, useState, type HTMLAttributes, type RefObject, } from "react"; import { IconArrowsMaximize } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import type { ActiveThread } from "@/lib/threads/active-threads"; import type { TaskSession } from "@/lib/types/http"; import { useTranslation } from "react-i18next"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { selectThreadSessionId } from "@/lib/threads/thread-session-selection"; import { resolveThreadColumnStatus, type ThreadStatus } from "@/lib/threads/thread-session-status"; import { ThreadConversation } from "./thread-conversation"; import { ComposerDisclosureContext } from "@/components/task/chat/composer-disclosure"; import { useComposerDisclosure } from "@/components/task/chat/use-composer-disclosure"; import { ThreadSessionStatusIcon, ThreadSessionSwitcher } from "./thread-session-switcher"; import { ThreadTaskMenuButton, useThreadTaskContextMenu } from "./thread-task-actions"; import { MobileThreadColumnHeader, type MobileThreadNavigation, } from "./mobile-thread-column-header"; export function resolveThreadStatus(thread: ActiveThread): ThreadStatus { return resolveThreadColumnStatus({ taskState: thread.taskState, reviewStatus: thread.reviewStatus, taskPendingAction: thread.taskPendingAction, session: { state: thread.sessionState, pending_action: thread.pendingAction, }, }); } function statusSessionForThread(thread: ActiveThread, selectedSession: TaskSession | null) { if (!selectedSession) { return { state: thread.sessionState, pending_action: thread.pendingAction, }; } let pendingAction = selectedSession.pending_action; if (pendingAction === undefined) { pendingAction = selectedSession.id === thread.sessionId ? thread.pendingAction : null; } return { state: selectedSession.state, pending_action: pendingAction, foreground_activity: selectedSession.foreground_activity, }; } function ThreadMeta({ thread, status, sessions, selectedSessionId, onSelectSession, }: { thread: ActiveThread; status: ThreadStatus; sessions: readonly TaskSession[]; selectedSessionId: string | null; onSelectSession: (sessionId: string) => void; }) { const { t } = useTranslation(); const statusLabel = t(status.labelKey); return (
{statusLabel} {thread.workflowName} {thread.stepTitle && {thread.stepTitle}} {thread.activeSubagentCount > 0 && ( {t("threads:subagentCount", { count: thread.activeSubagentCount })} )} {thread.queuedPromptCount > 0 && ( {t("threads:queuedPromptCount", { count: thread.queuedPromptCount })} )}
); } /** * Keeps an initial deep link reachable until the reader interacts with the deck. * * Board resizing can interrupt the initial smooth scroll. Reassert the target * after that reflow, but never on ordinary transcript updates. */ function useScrollWhenFocused(isFocused: boolean, layoutKey: string) { const ref = useRef(null); useEffect(() => { if (!isFocused) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; ref.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: reducedMotion ? "instant" : "smooth", }); }, [isFocused, layoutKey]); return ref; } function ThreadSessionMembership({ taskId, requestedSessionId, selectedSessionId, onSelectSession, onSessions, onRequestedSessionResolved, onInvalidRequestedSession, }: { taskId: string; requestedSessionId: string | null; selectedSessionId: string | null; onSelectSession: (sessionId: string | null) => void; onSessions: (sessions: TaskSession[], isLoaded: boolean) => void; onRequestedSessionResolved: () => void; onInvalidRequestedSession?: (taskId: string, sessionId: string) => void; }) { const { t } = useTranslation(); const { sessions, isLoading, isLoaded, error, loadSessions } = useTaskSessions(taskId); const invalidRequestReportedRef = useRef(null); const resolvedSessionId = selectThreadSessionId(sessions, { requestedSessionId, currentSessionId: selectedSessionId, }); useEffect(() => { onSessions(sessions, isLoaded); }, [isLoaded, onSessions, sessions]); useEffect(() => { if (resolvedSessionId === selectedSessionId) return; onSelectSession(resolvedSessionId); }, [onSelectSession, resolvedSessionId, selectedSessionId]); useEffect(() => { if (!isLoaded || !requestedSessionId) return; onRequestedSessionResolved(); if (sessions.some((session) => session.id === requestedSessionId)) return; if (invalidRequestReportedRef.current === requestedSessionId) return; invalidRequestReportedRef.current = requestedSessionId; onInvalidRequestedSession?.(taskId, requestedSessionId); }, [ isLoaded, onInvalidRequestedSession, onRequestedSessionResolved, requestedSessionId, sessions, taskId, ]); if (isLoading) { return (
{t("threads:sessionListLoading")}
); } if (error) { return (
{t("threads:sessionListUnavailable")}
); } if (sessions.length === 0) { return (
{t("threads:sessionListEmpty")}
); } return null; } type ThreadColumnProps = { thread: ActiveThread; autoHideComposer?: boolean; mobileNavigation?: MobileThreadNavigation; isFocused?: boolean; layoutKey?: string; isPreloaded?: boolean; isDetailActive?: boolean; requestedSessionId?: string | null; onColumnRef?: (taskId: string, element: HTMLElement | null) => void; onInvalidRequestedSession?: (taskId: string, sessionId: string) => void; onOpenTask: (taskId: string) => void; }; function ThreadColumnHeader({ thread, status, sessions, selectedSessionId, onSelectSession, onOpenTask, mobileNavigation, }: { thread: ActiveThread; status: ThreadStatus; sessions: readonly TaskSession[]; selectedSessionId: string | null; onSelectSession: (sessionId: string) => void; onOpenTask: (taskId: string) => void; mobileNavigation?: MobileThreadNavigation; }) { const { t } = useTranslation(); const onContextMenu = useThreadTaskContextMenu(thread.taskId); if (mobileNavigation) { return ( ); } return (

{thread.title}

); } function ThreadColumnBody({ taskId, isPreloaded, isDetailActive, requestedSessionId, selectedSessionId, selectedSession, sessionListReady, onSelectSession, onSessions, onRequestedSessionResolved, onInvalidRequestedSession, }: { taskId: string; isPreloaded: boolean; isDetailActive: boolean; requestedSessionId: string | null; selectedSessionId: string | null; selectedSession: TaskSession | null; sessionListReady: boolean; onSelectSession: (sessionId: string | null) => void; onSessions: (sessions: TaskSession[], isLoaded: boolean) => void; onRequestedSessionResolved: () => void; onInvalidRequestedSession?: (taskId: string, sessionId: string) => void; }) { return (
{isPreloaded && ( )} {isDetailActive && sessionListReady && selectedSession && ( )}
); } function useThreadComposerInteraction( enabled: boolean, sessionId: string | null, tileRef: RefObject, ) { const controller = useComposerDisclosure({ enabled, sessionId }); const returningFocus = useRef(false); const disclosure = { ...controller, collapse: () => { if (!controller.canCollapse) return; controller.collapse(); returningFocus.current = true; tileRef.current?.focus({ preventScroll: true }); returningFocus.current = false; }, }; const bindings: HTMLAttributes = { tabIndex: enabled ? 0 : undefined, onPointerEnter: (event) => controller.pointerEnter(event.pointerType), onPointerLeave: (event) => controller.pointerLeave(event.pointerType), onFocusCapture: () => controller.focus(returningFocus.current), onBlurCapture: controller.blur, onKeyDown: (event) => { if (event.target === event.currentTarget && event.key === "Enter" && !event.repeat) { event.preventDefault(); controller.reveal(); } }, }; return { disclosure, bindings }; } export function ThreadColumn({ thread, autoHideComposer = false, mobileNavigation, isFocused = false, layoutKey = "columns", isPreloaded = false, isDetailActive = false, requestedSessionId = null, onColumnRef, onInvalidRequestedSession, onOpenTask, }: ThreadColumnProps) { const { t } = useTranslation(); const [selectedSessionId, setSelectedSessionId] = useState( () => requestedSessionId, ); const [sessions, setSessions] = useState([]); const [sessionListReady, setSessionListReady] = useState(false); const [requestedSessionResolved, setRequestedSessionResolved] = useState( () => !requestedSessionId, ); const requestedSessionRef = useRef(requestedSessionId); const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? null; const statusSession = statusSessionForThread(thread, selectedSession); const status = resolveThreadColumnStatus({ taskState: thread.taskState, reviewStatus: thread.reviewStatus, taskPendingAction: thread.taskPendingAction, session: statusSession, }); useEffect(() => { if (requestedSessionRef.current === requestedSessionId) return; requestedSessionRef.current = requestedSessionId; if (requestedSessionId) { setSelectedSessionId(requestedSessionId); setRequestedSessionResolved(false); } }, [requestedSessionId]); const handleSelectSession = useCallback((sessionId: string | null) => { setSelectedSessionId(sessionId); }, []); const handleSessionListState = useCallback((nextSessions: TaskSession[], isLoaded: boolean) => { setSessions(nextSessions); setSessionListReady(isLoaded); }, []); const handleRequestedSessionResolved = useCallback(() => { setRequestedSessionResolved(true); }, []); const focusRef = useScrollWhenFocused(isFocused, layoutKey); const composer = useThreadComposerInteraction( autoHideComposer, isDetailActive ? selectedSessionId : null, focusRef, ); const setColumnRef = useCallback( (element: HTMLElement | null) => { focusRef.current = element; onColumnRef?.(thread.taskId, element); }, [focusRef, onColumnRef, thread.taskId], ); return (
); }