"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 (
);
}