Files
kandev/apps/web/components/task/task-page-content.tsx
T
2026-09-21 23:20:52 +01:00

432 lines
15 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { useTranslation } from "react-i18next";
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 { 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 { useExternalVcsFileLinkHydration } from "@/hooks/domains/workspace/use-external-vcs-file-link";
import { fetchTask } from "@/lib/api";
import { linkToTaskOverview } from "@/lib/links";
import { useTasks } from "@/hooks/use-tasks";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useTaskCanvasesStateForTask } from "@/hooks/domains/task/use-task-canvases";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import type { Layout } from "react-resizable-panels";
import {
deriveIsAgentWorking,
buildArchivedValue,
hasResolvedTaskDetails,
resolveEffectiveTask,
resolveTaskContentState,
syncActiveTaskSession,
} from "@/components/task/task-page-content-helpers";
import { TaskPageInner } from "@/components/task/task-page-inner";
import { TaskRemovalBoundary } from "@/components/task/task-removal-boundary";
import { GridSpinner } from "@/components/grid-spinner";
type TaskPageContentProps = {
task: Task | null;
taskId?: string | null;
sessionId?: string | null;
initialRepositories?: Repository[];
initialScripts?: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts?: Record<string, Layout>;
initialLayout?: string | null;
officeTaskHref?: string | null;
};
export 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],
);
}
export 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,
};
}
export 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 };
}
function TaskLoadingState() {
const { t } = useTranslation();
return (
<div
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
data-testid="task-loading-state"
>
<div className="flex min-h-24 min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<GridSpinner className="text-primary" />
<span>{t("common:taskLoading")}</span>
</div>
</div>
);
}
export function TaskLoadErrorState() {
const { t } = useTranslation();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
return (
<div
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
data-testid="task-load-error-state"
>
<div className="flex min-h-24 max-w-sm min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<IconAlertTriangle className="h-5 w-5 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<div className="font-medium text-foreground">{t("common:taskUnavailable")}</div>
<div>{t("common:taskUnavailableDescription")}</div>
</div>
<Button
asChild
size="lg"
className="min-h-11 px-4 sm:min-h-9"
data-testid="task-unavailable-overview-link"
>
<Link href={linkToTaskOverview({ workspaceId: activeWorkspaceId ?? undefined })}>
{t("common:backToTaskOverview")}
</Link>
</Button>
</div>
</div>
);
}
function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) {
const [taskDetails, setTaskDetails] = useState<Task | null>(initialTask);
const [taskLoadError, setTaskLoadError] = useState<unknown | null>(null);
const activeTaskIdRef = useRef(activeTaskId);
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],
);
const hasTaskDetails = hasResolvedTaskDetails({
effectiveTaskId,
taskDetailsId: taskDetails?.id ?? null,
initialTaskId: initialTask?.id ?? null,
});
useTasks(task?.workflow_id ?? null);
useEffect(() => {
activeTaskIdRef.current = activeTaskId;
}, [activeTaskId]);
const loadTaskDetails = useCallback(async () => {
if (!activeTaskId) return;
const requestedTaskId = activeTaskId;
try {
const response = await fetchTask(requestedTaskId, { cache: "no-store" });
if (activeTaskIdRef.current !== requestedTaskId) return;
setTaskDetails(response);
setTaskLoadError(null);
} catch (error) {
if (activeTaskIdRef.current !== requestedTaskId) return;
console.error("[TaskPageContent] Failed to load task details:", error);
setTaskLoadError(error);
}
}, [activeTaskId]);
useEffect(() => {
if (!activeTaskId || taskDetails?.id === activeTaskId) {
setTaskLoadError(null);
return;
}
setTaskLoadError(null);
void loadTaskDetails();
}, [activeTaskId, taskDetails?.id, loadTaskDetails]);
useForegroundRefresh(loadTaskDetails, Boolean(activeTaskId), activeTaskId);
const onTaskUnarchived = useCallback(
(taskId: string) => {
if (activeTaskId !== taskId) return;
void loadTaskDetails();
},
[activeTaskId, loadTaskDetails],
);
return {
task,
kanbanTask,
taskLoadError: hasTaskDetails ? null : taskLoadError,
onTaskUnarchived,
refreshTask: loadTaskDetails,
};
}
function useTaskPageData(
initialTask: Task | null,
fallbackTaskId: string | null | undefined,
sessionId: string | null,
initialRepositories: Repository[],
) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const setActiveSessionAuto = useAppStore((state) => state.setActiveSessionAuto);
const setActiveTask = useAppStore((state) => state.setActiveTask);
const previousRouteTaskId = useRef<string | null | undefined>(undefined);
// Route synchronization must see the current selection when route data or
// delayed session hydration changes, but a sidebar selection must not
// retrigger it and overwrite the session chosen by task selection.
const activeTaskIdForSyncRef = useRef(activeTaskId);
activeTaskIdForSyncRef.current = activeTaskId;
// 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, taskLoadError, onTaskUnarchived, refreshTask } = useTaskDetails(
activeTaskId,
initialTask,
);
const agent = useSessionAgent(task);
const ensureSession = useEnsureTaskSession({
id: task?.id,
workflowStepId: task?.workflow_step_id,
workflowId: task?.workflow_id,
});
const initialSessionId = sessionId ?? agent.taskSessionId ?? null;
const effectiveSessionId = validatedActiveSessionId ?? initialSessionId;
useEffect(() => {
const applied = syncActiveTaskSession({
initialTaskId: initialTask?.id,
fallbackTaskId,
initialSessionId,
activeTaskId: activeTaskIdForSyncRef.current,
previousRouteTaskId: previousRouteTaskId.current,
setActiveSessionAuto,
setActiveTask,
});
if (applied) previousRouteTaskId.current = initialTask?.id ?? fallbackTaskId;
}, [initialTask?.id, fallbackTaskId, initialSessionId, setActiveSessionAuto, 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,
taskLoadError,
agent,
effectiveSessionId,
repository,
repositories: effectiveRepositories,
ensureSession,
onTaskUnarchived,
refreshTask,
};
}
function TaskPageContentLive({
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 canvasesEnabled = useFeature("canvases");
const connectionStatus = useAppStore((state) => state.connection.status);
const {
task,
taskLoadError,
agent,
effectiveSessionId,
repository,
repositories,
ensureSession,
onTaskUnarchived,
refreshTask,
} = useTaskPageData(initialTask, initialTaskId, sessionId, initialRepositories);
const taskCanvasesState = useTaskCanvasesStateForTask(task, canvasesEnabled);
useExternalVcsFileLinkHydration(task, repositories);
const workflowSteps = useWorkflowStepsMapped();
const sessionPanel = useSessionPanelState(effectiveSessionId);
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
const resumption = useSessionResumption(
task?.id ?? null,
effectiveSessionId,
task ? task.archived_at != null : null,
{ onTaskArchiveConflict: refreshTask },
);
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));
}, []);
const contentState = resolveTaskContentState({
isMounted,
hasTask: Boolean(task),
hasTaskLoadError: Boolean(taskLoadError),
});
if (contentState === "loading") return <TaskLoadingState />;
if (contentState === "error") return <TaskLoadErrorState />;
if (!task) return <TaskLoadErrorState />;
return (
<TaskPageInner
task={task}
effectiveSessionId={effectiveSessionId ?? null}
repository={repository}
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}
officeTaskHref={officeTaskHref}
ensureSession={ensureSession}
onTaskUnarchived={onTaskUnarchived}
taskCanvases={taskCanvasesState.canvases}
taskCanvasesStatus={taskCanvasesState.status}
/>
);
}
export function TaskPageContent(props: TaskPageContentProps) {
const taskId = props.taskId ?? props.task?.id ?? null;
return (
<TaskRemovalBoundary taskId={taskId}>
<TaskPageContentLive {...props} />
</TaskRemovalBoundary>
);
}