"use client"; import { useCallback, useEffect, useRef, useState } 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 { ThreadSessionStatusIcon, ThreadSessionSwitcher } from "./thread-session-switcher"; 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 })} )}
); } /** * Brings a deep-linked column into view once. * * The effect is keyed on `isFocused` alone, and the column is keyed by task id, * so a column that only mounts when a later snapshot lands still scrolls, while * a column that merely re-renders with fresh messages does not yank the deck * back under the reader. */ function useScrollWhenFocused(isFocused: boolean) { const ref = useRef(null); useEffect(() => { if (!isFocused) return; ref.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" }); }, [isFocused]); 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; isFocused?: boolean; 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, }: { thread: ActiveThread; status: ThreadStatus; sessions: readonly TaskSession[]; selectedSessionId: string | null; onSelectSession: (sessionId: string) => void; onOpenTask: (taskId: string) => void; }) { const { t } = useTranslation(); 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 && ( )}
); } export function ThreadColumn({ thread, isFocused = false, 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 handleSessions = useCallback((nextSessions: TaskSession[]) => { setSessions(nextSessions); }, []); const handleSessionListState = useCallback( (nextSessions: TaskSession[], isLoaded: boolean) => { handleSessions(nextSessions); setSessionListReady(isLoaded); }, [handleSessions], ); const handleRequestedSessionResolved = useCallback(() => { setRequestedSessionResolved(true); }, []); const handleInvalidRequestedSession = useCallback( (taskId: string, sessionId: string) => { onInvalidRequestedSession?.(taskId, sessionId); }, [onInvalidRequestedSession], ); const focusRef = useScrollWhenFocused(isFocused); const setColumnRef = useCallback( (element: HTMLElement | null) => { focusRef.current = element; onColumnRef?.(thread.taskId, element); }, [focusRef, onColumnRef, thread.taskId], ); return (
); }