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