"use client"; import { useEffect, useMemo, useState } from "react"; import { TaskTopBar } from "@/components/task/task-top-bar"; import { TaskLayout } from "@/components/task/task-layout"; import { DebugOverlay } from "@/components/debug-overlay"; import { taskId as toTaskId, workflowId as toWorkflowId, workspaceId as toWorkspaceId, type Repository, type RepositoryScript, type Task, } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import type { KanbanState } from "@/lib/state/slices"; import { isDebugUI } from "@/lib/config"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useSessionAgent } from "@/hooks/domains/session/use-session-agent"; import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption"; import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl"; import { useTaskFocus } from "@/hooks/domains/session/use-task-focus"; import { useAppStore } from "@/components/state-provider"; import { useEnsureTaskSession } from "@/hooks/domains/session/use-ensure-task-session"; import { fetchTask } from "@/lib/api"; import { useTasks } from "@/hooks/use-tasks"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { Layout } from "react-resizable-panels"; import { TaskArchivedProvider } from "./task-archived-context"; import { SessionCommands } from "@/components/session-commands"; import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs"; import { buildDebugEntries, deriveIsAgentWorking, buildArchivedValue, resolveTaskProps, } from "@/components/task/task-page-content-helpers"; type TaskPageContentProps = { task: Task | null; taskId?: string | null; sessionId?: string | null; initialRepositories?: Repository[]; initialScripts?: RepositoryScript[]; initialTerminals?: Terminal[]; defaultLayouts?: Record; initialLayout?: string | null; officeTaskHref?: string | null; }; function resolveEffectiveTask( taskDetails: Task | null, initialTask: Task | null, kanbanTask: KanbanState["tasks"][number] | null, effectiveTaskId: string | null, ): Task | null { const matchingTaskDetails = taskDetails?.id === effectiveTaskId ? taskDetails : null; const matchingInitialTask = initialTask?.id === effectiveTaskId ? initialTask : null; const baseTask = matchingTaskDetails ?? matchingInitialTask; if (!baseTask && !kanbanTask) return null; if (baseTask) return mergeBaseWithKanban(baseTask, kanbanTask); if (kanbanTask) return buildTaskFromKanban(kanbanTask, taskDetails, initialTask); return null; } function mergeBaseWithKanban( baseTask: Task, kanbanTask: KanbanState["tasks"][number] | null, ): Task { if (!kanbanTask) return baseTask; return { ...baseTask, title: kanbanTask.title ?? baseTask.title, description: kanbanTask.description ?? baseTask.description, workflow_step_id: (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id, position: kanbanTask.position ?? baseTask.position, state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state, repositories: baseTask.repositories, }; } function buildTaskFromKanban( kanbanTask: KanbanState["tasks"][number], taskDetails: Task | null, initialTask: Task | null, ): Task { const prevWorkspaceId = taskDetails?.workspace_id ?? initialTask?.workspace_id; const prevBoardId = taskDetails?.workflow_id ?? initialTask?.workflow_id; return { id: toTaskId(kanbanTask.id), title: kanbanTask.title, description: kanbanTask.description ?? "", workflow_step_id: kanbanTask.workflowStepId, position: kanbanTask.position, state: kanbanTask.state ?? "CREATED", workspace_id: prevWorkspaceId ?? toWorkspaceId(""), workflow_id: prevBoardId ?? toWorkflowId(""), priority: 0, repositories: [], created_at: "", updated_at: kanbanTask.updatedAt ?? "", }; } function useWorkflowStepsMapped() { const kanbanSteps = useAppStore((state) => state.kanban.steps); return useMemo( () => kanbanSteps.map((s) => ({ id: s.id, name: s.title, color: s.color, position: s.position, events: s.events, allow_manual_move: s.allow_manual_move, prompt: s.prompt, is_start_step: s.is_start_step, agent_profile_id: s.agent_profile_id, })), [kanbanSteps], ); } function useSessionPanelState(effectiveSessionId: string | null | undefined) { const storeSessionState = useAppStore((state) => effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.state ?? null) : null, ); const isSessionPassthrough = useAppStore((state) => effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.is_passthrough === true : false, ); // Use the task-level workflow step for the top-bar stepper. Individual sessions // may lag behind (e.g. a completed session stays at its old step), but the // task's step reflects the current workflow position and stays stable across // tab switches within the same task. const sessionWorkflowStepId = useAppStore((state) => { const taskId = state.tasks.activeTaskId; if (!taskId) return null; const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId); return (task?.workflowStepId as string) ?? null; }); const previewOpen = useAppStore((state) => effectiveSessionId ? (state.previewPanel.openBySessionId[effectiveSessionId] ?? false) : false, ); const previewStage = useAppStore((state) => effectiveSessionId ? (state.previewPanel.stageBySessionId[effectiveSessionId] ?? "closed") : "closed", ); const previewUrl = useAppStore((state) => effectiveSessionId ? (state.previewPanel.urlBySessionId[effectiveSessionId] ?? "") : "", ); const devProcessId = useAppStore((state) => effectiveSessionId ? state.processes.devProcessBySessionId[effectiveSessionId] : undefined, ); const devProcessStatus = useAppStore((state) => devProcessId ? (state.processes.processesById[devProcessId]?.status ?? null) : null, ); return { storeSessionState, isSessionPassthrough, sessionWorkflowStepId, previewOpen, previewStage, previewUrl, devProcessId, devProcessStatus, }; } function useMergedAgentState( agent: ReturnType, resumption: ReturnType, sessionPanel: ReturnType, effectiveSessionId: string | null | undefined, task: Task | null, ) { const isResuming = resumption.resumptionState === "checking" || resumption.resumptionState === "resuming"; const isResumed = resumption.resumptionState === "resumed" || resumption.resumptionState === "running"; const taskSessionState = sessionPanel.storeSessionState ?? agent.taskSessionState; const worktreePath = effectiveSessionId ? (resumption.worktreePath ?? agent.worktreePath) : agent.worktreePath; const worktreeBranch = effectiveSessionId ? (resumption.worktreeBranch ?? agent.worktreeBranch) : agent.worktreeBranch; const isAgentWorking = deriveIsAgentWorking( taskSessionState, agent.isAgentRunning, task?.state ?? null, ); return { isResuming, isResumed, taskSessionState, worktreePath, worktreeBranch, isAgentWorking }; } type TaskPageInnerProps = { task: Task | null; effectiveSessionId: string | null; repository: Repository | null; agent: ReturnType; merged: ReturnType; resumption: ReturnType; sessionPanel: ReturnType; agentctlStatus: ReturnType; connectionStatus: string; workflowSteps: ReturnType; archivedValue: ReturnType; isMobile: boolean; showDebugOverlay: boolean; onToggleDebugOverlay: () => void; initialScripts: RepositoryScript[]; initialTerminals?: Terminal[]; defaultLayouts: Record; initialLayout?: string | null; officeTaskHref?: string | null; }; type RemoteExecutorStatus = { is_remote_executor?: boolean; executor_type?: string | null; executor_name?: string | null; remote_name?: string | null; remote_state?: string | null; remote_created_at?: string | null; remote_checked_at?: string | null; remote_status_error?: string | null; }; function toNullable(value: string | null | undefined): string | null { return value ?? null; } function resolveRemoteExecutor(status?: RemoteExecutorStatus | null) { const remoteExecutorName = status?.remote_name ?? status?.executor_name ?? null; return { isRemoteExecutor: status?.is_remote_executor ?? false, remoteExecutorType: toNullable(status?.executor_type), remoteExecutorName, remoteState: toNullable(status?.remote_state), remoteCreatedAt: toNullable(status?.remote_created_at), remoteCheckedAt: toNullable(status?.remote_checked_at), remoteStatusError: toNullable(status?.remote_status_error), }; } /** Pick the best step ID. Both session-level (from session.state_changed WS event) and * task-level (from task.updated WS event + kanban snapshot) carry the same value, but * session.state_changed is delivered directly while task.updated goes through the hub's * broadcast channel, so the session value arrives first and is less likely to be stale. */ function resolveCurrentStepId( sessionStepId: string | null, taskStepId: string | null, ): string | null { return sessionStepId || taskStepId || null; } function buildTaskTopBarProps(params: { taskProps: ReturnType; agent: ReturnType; merged: ReturnType; workflowSteps: ReturnType; showDebugOverlay: boolean; onToggleDebugOverlay: () => void; effectiveSessionId: string | null; remote: ReturnType; sessionWorkflowStepId: string | null; agentctlReady: boolean; officeTaskHref?: string | null; }) { const { taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay } = params; return { taskId: taskProps.taskId, activeSessionId: params.effectiveSessionId, taskTitle: taskProps.taskTitle, onStartAgent: agent.handleStartAgent, onStopAgent: agent.handleStopAgent, isAgentRunning: agent.isAgentRunning || merged.isResumed, isAgentLoading: agent.isAgentLoading || merged.isResuming, showDebugOverlay, onToggleDebugOverlay, workflowSteps, currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId), workflowId: taskProps.workflowId, workspaceId: taskProps.workspaceId, isArchived: taskProps.isArchived, isRemoteExecutor: params.remote.isRemoteExecutor, isAgentctlReady: params.agentctlReady, remoteExecutorType: params.remote.remoteExecutorType, officeTaskHref: params.officeTaskHref, }; } function buildTaskLayoutProps(params: { taskProps: ReturnType; repository: Repository | null; effectiveSessionId: string | null; initialScripts: RepositoryScript[]; initialTerminals?: Terminal[]; defaultLayouts: Record; merged: ReturnType; remote: ReturnType; initialLayout?: string | null; }) { const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params; return { workspaceId: taskProps.workspaceId, workflowId: taskProps.workflowId, sessionId: effectiveSessionId, repository: repository ?? null, initialScripts, initialTerminals, defaultLayouts: params.defaultLayouts, initialLayout: params.initialLayout, taskTitle: taskProps.taskTitle, baseBranch: taskProps.baseBranch, worktreeBranch: params.merged.worktreeBranch, isRemoteExecutor: params.remote.isRemoteExecutor, remoteExecutorType: params.remote.remoteExecutorType, remoteExecutorName: params.remote.remoteExecutorName, remoteState: params.remote.remoteState, remoteCreatedAt: params.remote.remoteCreatedAt, remoteCheckedAt: params.remote.remoteCheckedAt, remoteStatusError: params.remote.remoteStatusError, }; } function maybeBuildDebugEntries(params: { isVisible: boolean; connectionStatus: string; task: Task | null; effectiveSessionId: string | null | undefined; merged: ReturnType; resumption: ReturnType; sessionPanel: ReturnType; agentctlStatus: ReturnType; }) { if (!params.isVisible) return null; return buildDebugEntries({ connectionStatus: params.connectionStatus, task: params.task, effectiveSessionId: params.effectiveSessionId, taskSessionState: params.merged.taskSessionState, isAgentWorking: params.merged.isAgentWorking, resumptionState: params.resumption.resumptionState, resumptionError: params.resumption.error, agentctlStatus: params.agentctlStatus, previewOpen: params.sessionPanel.previewOpen, previewStage: params.sessionPanel.previewStage, previewUrl: params.sessionPanel.previewUrl, devProcessId: params.sessionPanel.devProcessId, devProcessStatus: params.sessionPanel.devProcessStatus, }); } function TaskPageInner({ task, effectiveSessionId, repository, agent, merged, resumption, sessionPanel, agentctlStatus, connectionStatus, workflowSteps, archivedValue, isMobile, showDebugOverlay, onToggleDebugOverlay, initialScripts, initialTerminals, defaultLayouts, initialLayout, officeTaskHref, }: TaskPageInnerProps) { const taskProps = resolveTaskProps(task, repository); const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null); const debugEntries = maybeBuildDebugEntries({ isVisible: isDebugUI() && showDebugOverlay, connectionStatus, task, effectiveSessionId, merged, resumption, sessionPanel, agentctlStatus, }); const topBarProps = buildTaskTopBarProps({ taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay, effectiveSessionId, remote, sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId, agentctlReady: agentctlStatus.isReady, officeTaskHref, }); const layoutProps = buildTaskLayoutProps({ taskProps, repository, effectiveSessionId, initialScripts, initialTerminals, defaultLayouts, merged, remote, initialLayout, }); return (
{debugEntries && } {!isMobile && }
); } function syncActiveTaskSession(params: { initialTaskId: string | undefined; fallbackTaskId: string | null | undefined; initialSessionId: string | null; setActiveSession: (taskId: string, sessionId: string) => void; setActiveTask: (taskId: string) => void; }) { const taskId = params.initialTaskId ?? params.fallbackTaskId; if (!taskId) return; if (params.initialSessionId) params.setActiveSession(taskId, params.initialSessionId); else params.setActiveTask(taskId); } function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) { const [taskDetails, setTaskDetails] = useState(initialTask); const kanbanTask = useAppStore((state) => activeTaskId ? (state.kanban.tasks.find( (item: KanbanState["tasks"][number]) => item.id === activeTaskId, ) ?? null) : null, ); const effectiveTaskId = activeTaskId ?? initialTask?.id ?? null; const task = useMemo( () => resolveEffectiveTask(taskDetails, initialTask, kanbanTask, effectiveTaskId), [taskDetails, initialTask, kanbanTask, effectiveTaskId], ); useTasks(task?.workflow_id ?? null); useEffect(() => { if (!activeTaskId || taskDetails?.id === activeTaskId) return; fetchTask(activeTaskId, { cache: "no-store" }) .then((response) => setTaskDetails(response)) .catch((error) => console.error("[TaskPageContent] Failed to load task details:", error)); }, [ activeTaskId, taskDetails?.id, taskDetails?.workspace_id, taskDetails?.workflow_id, kanbanTask, setTaskDetails, ]); return { task, kanbanTask }; } function useTaskPageData( initialTask: Task | null, fallbackTaskId: string | null | undefined, sessionId: string | null, initialRepositories: Repository[], ) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const setActiveSession = useAppStore((state) => state.setActiveSession); const setActiveTask = useAppStore((state) => state.setActiveTask); // Validate that activeSessionId belongs to activeTaskId to prevent showing // messages from an unrelated session when navigating to a task without sessions. const validatedActiveSessionId = useAppStore((state) => { const sid = state.tasks.activeSessionId; if (!sid || !activeTaskId) return null; const session = state.taskSessions.items[sid]; return session?.task_id === activeTaskId ? sid : null; }); const { task } = useTaskDetails(activeTaskId, initialTask); const agent = useSessionAgent(task); useEnsureTaskSession(task); const initialSessionId = sessionId ?? agent.taskSessionId ?? null; const effectiveSessionId = validatedActiveSessionId ?? initialSessionId; useEffect(() => { syncActiveTaskSession({ initialTaskId: initialTask?.id, fallbackTaskId, initialSessionId, setActiveSession, setActiveTask, }); }, [initialTask?.id, fallbackTaskId, initialSessionId, setActiveSession, setActiveTask]); const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const effectiveRepositories = repositories.length ? repositories : initialRepositories; const repository = useMemo( () => effectiveRepositories.find( (item: Repository) => item.id === task?.repositories?.[0]?.repository_id, ) ?? null, [effectiveRepositories, task?.repositories], ); return { task, agent, effectiveSessionId, repository }; } export function TaskPageContent({ task: initialTask, taskId: initialTaskId = null, sessionId = null, initialRepositories = [], initialScripts = [], initialTerminals, defaultLayouts = {}, initialLayout, officeTaskHref = null, }: TaskPageContentProps) { const [isMounted, setIsMounted] = useState(false); const [showDebugOverlay, setShowDebugOverlay] = useState(false); const { isMobile } = useResponsiveBreakpoint(); const connectionStatus = useAppStore((state) => state.connection.status); const { task, agent, effectiveSessionId, repository } = useTaskPageData( initialTask, initialTaskId, sessionId, initialRepositories, ); const workflowSteps = useWorkflowStepsMapped(); const sessionPanel = useSessionPanelState(effectiveSessionId); const agentctlStatus = useSessionAgentctl(effectiveSessionId); const resumption = useSessionResumption(task?.id ?? null, effectiveSessionId); const merged = useMergedAgentState(agent, resumption, sessionPanel, effectiveSessionId, task); const archivedValue = useMemo(() => buildArchivedValue(task, repository), [task, repository]); // Mark this session as actively focused so the backend lifts polling to fast. // Sidebar cards subscribe but never focus, so they stay on the cheap slow tier. useTaskFocus(effectiveSessionId); useEffect(() => { queueMicrotask(() => setIsMounted(true)); }, []); if (!isMounted) return
; return ( setShowDebugOverlay((prev) => !prev)} initialScripts={initialScripts} initialTerminals={initialTerminals} defaultLayouts={defaultLayouts} initialLayout={initialLayout} officeTaskHref={officeTaskHref} /> ); }