Files
kandev/apps/web/components/task/task-page-content.tsx
T
2026-03-30 22:44:10 +01:00

626 lines
22 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, 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 type { Repository, RepositoryScript, Task } from "@/lib/types/http";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
import type { KanbanState } from "@/lib/state/slices";
import { DEBUG_UI } 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 { useAppStore } from "@/components/state-provider";
import { useTaskSessions } from "@/hooks/use-task-sessions";
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";
// Stable empty array used as the fallback in useAppStore selectors that return
// arrays — avoids creating a new reference on every call, which would cause
// React's useSyncExternalStore (getServerSnapshot) to throw an infinite-loop
// error during SSR / hydration.
const EMPTY_SESSIONS: never[] = [];
type TaskPageContentProps = {
task: Task | null;
taskId?: string | null;
sessionId?: string | null;
initialRepositories?: Repository[];
initialScripts?: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts?: Record<string, Layout>;
initialLayout?: 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: kanbanTask.id,
title: kanbanTask.title,
description: kanbanTask.description ?? "",
workflow_step_id: kanbanTask.workflowStepId,
position: kanbanTask.position,
state: kanbanTask.state ?? "CREATED",
workspace_id: prevWorkspaceId ?? "",
workflow_id: prevBoardId ?? "",
priority: 0,
repositories: [],
created_at: "",
updated_at: kanbanTask.updatedAt ?? "",
} as Task;
}
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,
})),
[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<typeof useSessionAgent>,
resumption: ReturnType<typeof useSessionResumption>,
sessionPanel: ReturnType<typeof useSessionPanelState>,
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<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
resumption: ReturnType<typeof useSessionResumption>;
sessionPanel: ReturnType<typeof useSessionPanelState>;
agentctlStatus: ReturnType<typeof useSessionAgentctl>;
connectionStatus: string;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
archivedValue: ReturnType<typeof buildArchivedValue>;
isMobile: boolean;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
initialLayout?: 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<typeof resolveTaskProps>;
agent: ReturnType<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
agentctlReady: boolean;
}) {
const { taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay } =
params;
return {
taskId: taskProps.taskId,
activeSessionId: params.effectiveSessionId,
taskTitle: taskProps.taskTitle,
baseBranch: taskProps.baseBranch ?? undefined,
onStartAgent: agent.handleStartAgent,
onStopAgent: agent.handleStopAgent,
isAgentRunning: agent.isAgentRunning || merged.isResumed,
isAgentLoading: agent.isAgentLoading || merged.isResuming,
worktreePath: merged.worktreePath,
worktreeBranch: merged.worktreeBranch,
repositoryPath: taskProps.repositoryPath,
repositoryName: taskProps.repositoryName,
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,
remoteExecutorName: params.remote.remoteExecutorName,
remoteState: params.remote.remoteState,
remoteCreatedAt: params.remote.remoteCreatedAt,
remoteCheckedAt: params.remote.remoteCheckedAt,
remoteStatusError: params.remote.remoteStatusError,
};
}
function buildTaskLayoutProps(params: {
taskProps: ReturnType<typeof resolveTaskProps>;
repository: Repository | null;
effectiveSessionId: string | null;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
merged: ReturnType<typeof useMergedAgentState>;
remote: ReturnType<typeof resolveRemoteExecutor>;
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<typeof useMergedAgentState>;
resumption: ReturnType<typeof useSessionResumption>;
sessionPanel: ReturnType<typeof useSessionPanelState>;
agentctlStatus: ReturnType<typeof useSessionAgentctl>;
}) {
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,
}: TaskPageInnerProps) {
const taskProps = resolveTaskProps(task, repository);
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
const debugEntries = maybeBuildDebugEntries({
isVisible: DEBUG_UI && 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,
});
const layoutProps = buildTaskLayoutProps({
taskProps,
repository,
effectiveSessionId,
initialScripts,
initialTerminals,
defaultLayouts,
merged,
remote,
initialLayout,
});
return (
<TooltipProvider>
<VcsDialogsProvider
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="h-screen w-full flex flex-col bg-background overflow-hidden">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
/>
{debugEntries && <DebugOverlay title="Task Debug" entries={debugEntries} />}
{!isMobile && <TaskTopBar {...topBarProps} />}
<TaskArchivedProvider value={archivedValue}>
<TaskLayout {...layoutProps} />
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</TooltipProvider>
);
}
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<Task | null>(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 useAutoStartSession(
task: Task | null,
handleStartAgent: (
agentProfileId: string,
opts?: { prompt?: string; autoStart?: boolean },
) => Promise<void>,
) {
const { isLoaded } = useTaskSessions(task?.id ?? null);
const sessions = useAppStore((state) =>
task?.id ? (state.taskSessionsByTask.itemsByTaskId[task.id] ?? EMPTY_SESSIONS) : EMPTY_SESSIONS,
);
const workspaceDefaultProfileId = useAppStore((state) => {
const activeId = state.workspaces.activeId;
if (!activeId) return null;
return state.workspaces.items.find((w) => w.id === activeId)?.default_agent_profile_id ?? null;
});
const taskMetaProfileId = (task?.metadata as Record<string, unknown> | null)?.agent_profile_id;
const agentProfileId =
typeof taskMetaProfileId === "string" ? taskMetaProfileId : (workspaceDefaultProfileId ?? null);
const startedRef = useRef(false);
useEffect(() => {
if (!task?.id) return;
if (!isLoaded) return;
if (sessions.length > 0) return;
if (!agentProfileId) return;
if (startedRef.current) return;
startedRef.current = true;
handleStartAgent(agentProfileId, { autoStart: true }).catch(() => {
startedRef.current = false;
});
}, [task?.id, isLoaded, sessions.length, agentProfileId, handleStartAgent]);
}
function useTaskPageData(
initialTask: Task | null,
fallbackTaskId: string | null | undefined,
sessionId: string | null,
initialRepositories: Repository[],
) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const setActiveTask = useAppStore((state) => state.setActiveTask);
const { task } = useTaskDetails(activeTaskId, initialTask);
const agent = useSessionAgent(task);
useAutoStartSession(task, agent.handleStartAgent);
const initialSessionId = sessionId ?? agent.taskSessionId ?? null;
const effectiveSessionId = activeSessionId ?? 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,
}: 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]);
useEffect(() => {
queueMicrotask(() => setIsMounted(true));
}, []);
if (!isMounted) return <div className="h-screen w-full bg-background" />;
return (
<TaskPageInner
task={task}
effectiveSessionId={effectiveSessionId ?? null}
repository={repository}
agent={agent}
merged={merged}
resumption={resumption}
sessionPanel={sessionPanel}
agentctlStatus={agentctlStatus}
connectionStatus={connectionStatus}
workflowSteps={workflowSteps}
archivedValue={archivedValue}
isMobile={isMobile}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={() => setShowDebugOverlay((prev) => !prev)}
initialScripts={initialScripts}
initialTerminals={initialTerminals}
defaultLayouts={defaultLayouts}
initialLayout={initialLayout}
/>
);
}