Files
kandev/apps/web/components/task/task-page-inner.tsx
T

438 lines
16 KiB
TypeScript

"use client";
import { useCallback, useEffect, 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, type RepositoryScript, type Task } from "@/lib/types/http";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
import { isDebugUI } from "@/lib/config";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session";
import {
EnsureSessionErrorBanner,
SessionRecoveryFeedback,
} from "@/components/task/ensure-session-error";
import { TaskMoveErrorBanner } from "@/components/task/task-move-error-banner";
import type { Layout } from "react-resizable-panels";
import { TaskArchivedProvider } from "./task-archived-context";
import { SessionCommands } from "@/components/session-commands";
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
import { useEmbeddedVscodeSupport } from "@/components/task/task-page-editor-capability";
import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs";
import { PortForwardingVisibilityProvider } from "@/components/task/port-forwarding-visibility-provider";
import {
TaskLaunchErrorProvider,
useTaskLaunchErrorContext,
} from "@/components/task/task-launch-error-context";
import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
import {
buildDebugEntries,
buildArchivedValue,
resolveTaskProps,
useTaskActionsMenuBoardRow,
selectWorkspaceRepositories,
} from "@/components/task/task-page-content-helpers";
import type { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
import type { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
import type {
useWorkflowStepsMapped,
useSessionPanelState,
useMergedAgentState,
} from "./task-page-content";
import { useTranslation } from "react-i18next";
import type { Canvas } from "@/lib/api/domains/canvas-api";
export type TaskPageInnerProps = {
task: Task | null;
effectiveSessionId: string | null;
repository: Repository | null;
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;
officeTaskHref?: string | null;
ensureSession: UseEnsureTaskSessionResult;
onTaskUnarchived: (taskId: string) => void;
taskCanvases?: Canvas[];
};
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;
capabilities?: {
embedded_vscode?: boolean;
};
};
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),
};
}
// Prefer the session-level step (delivered direct via session.state_changed) over the task-level step (routed through the hub broadcast and slightly stale).
function resolveCurrentStepId(
sessionStepId: string | null,
taskStepId: string | null,
): string | null {
return sessionStepId || taskStepId || null;
}
function buildTaskTopBarProps(params: {
taskProps: ReturnType<typeof resolveTaskProps>;
actionsMenuBoardRow: ReturnType<typeof useTaskActionsMenuBoardRow>;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
embeddedVscodeSupported: boolean;
officeTaskHref?: string | null;
onTaskUnarchived: (taskId: string) => void;
}) {
const { taskProps, workflowSteps, showDebugOverlay, onToggleDebugOverlay } = params;
return {
taskId: taskProps.taskId,
activeSessionId: params.effectiveSessionId,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId),
workflowId: taskProps.workflowId,
workspaceId: taskProps.workspaceId,
projectId: taskProps.projectId,
issueUrl: taskProps.issueUrl,
issueNumber: taskProps.issueNumber,
isArchived: taskProps.isArchived,
embeddedVscodeSupported: params.embeddedVscodeSupported,
remoteExecutorType: params.remote.remoteExecutorType,
officeTaskHref: params.officeTaskHref,
onTaskUnarchived: params.onTaskUnarchived,
actionsMenuBoardRow: params.actionsMenuBoardRow,
// The subject's own last-known values, independent of `actionsMenuBoardRow`:
// the board excludes archived (and can lag/miss cross-workflow) tasks, so
// these stay available for the actions menu's plugin context and
// executor-aware confirmation copy even when the board row is unresolvable.
subjectWorkflowStepId: taskProps.workflowStepId,
subjectPrimaryExecutorType: taskProps.primaryExecutorType,
};
}
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;
onTaskUnarchived: (taskId: string) => void;
taskCanvases?: Canvas[];
}) {
const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params;
return {
taskId: taskProps.taskId,
workspaceId: taskProps.workspaceId,
workflowId: taskProps.workflowId,
sessionId: effectiveSessionId,
repository: repository ?? null,
initialScripts,
initialTerminals,
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskCanvases: params.taskCanvases ?? [],
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
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,
isArchived: taskProps.isArchived,
onTaskUnarchived: params.onTaskUnarchived,
};
}
function maybeBuildDebugEntries(params: {
isVisible: boolean;
connectionStatus: string;
task: Task | null;
effectiveSessionId: string | null | undefined;
activeSessionMetadata?: Record<string, unknown> | null;
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,
activeSessionMetadata: params.activeSessionMetadata,
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 TaskDebugOverlay({ entries }: { entries: ReturnType<typeof maybeBuildDebugEntries> }) {
const { t } = useTranslation();
if (!entries) return null;
return <DebugOverlay title={t("task:taskDebug")} entries={entries} />;
}
function TaskPageRecoveryFeedback({
taskId,
sessionId,
resumption,
workspaceId,
isPassthrough,
}: {
taskId: string;
sessionId: string | null;
resumption: TaskPageInnerProps["resumption"];
workspaceId: string | null;
isPassthrough: boolean;
}) {
const launchErrorContext = useTaskLaunchErrorContext();
const sessionMetadata = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId]?.metadata ?? null) : null,
);
const bootstrapRecoveryError = selectSessionRecoveryError(
launchErrorContext?.statusSummary?.active_error,
sessionId,
sessionMetadata,
);
if (bootstrapRecoveryError && sessionId && isPassthrough) {
return (
<SessionBootstrapRecoveryCard
taskId={taskId}
sessionId={sessionId}
workspaceId={workspaceId}
error={bootstrapRecoveryError}
automaticRecovery={resumption}
/>
);
}
if (bootstrapRecoveryError) {
return null;
}
return (
<SessionRecoveryFeedback
error={resumption.error}
notice={resumption.notice}
recoveryFailure={resumption.recoveryFailure}
onRetry={() => void resumption.resumeSession()}
retryDisabled={
resumption.resumptionState === "checking" || resumption.resumptionState === "resuming"
}
workspaceId={workspaceId}
/>
);
}
/**
* Derives everything the task page renders from its inputs: the resolved task
* props plus the three prop bundles handed to the debug overlay, top bar, and
* layout. Kept out of `TaskPageInner` so that component stays a wiring shell.
*/
function useTaskPageDerivedProps({
task,
effectiveSessionId,
repository,
merged,
resumption,
sessionPanel,
agentctlStatus,
connectionStatus,
workflowSteps,
showDebugOverlay,
onToggleDebugOverlay,
initialScripts,
initialTerminals,
defaultLayouts,
initialLayout,
officeTaskHref,
onTaskUnarchived,
taskCanvases,
}: TaskPageInnerProps) {
const workspaceRepositories = useAppStore((state) =>
selectWorkspaceRepositories(state.repositories.itemsByWorkspaceId, task?.workspace_id),
);
const taskProps = resolveTaskProps(task, repository, workspaceRepositories);
const actionsMenuBoardRow = useTaskActionsMenuBoardRow(task);
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
const embeddedVscode = useEmbeddedVscodeSupport(effectiveSessionId, resumption.sessionStatus);
const activeSessionMetadata = useAppStore((state) =>
effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.metadata ?? null) : null,
);
const debugEntries = maybeBuildDebugEntries({
isVisible: isDebugUI() && showDebugOverlay,
connectionStatus,
task,
effectiveSessionId,
activeSessionMetadata,
merged,
resumption,
sessionPanel,
agentctlStatus,
});
const topBarProps = buildTaskTopBarProps({
taskProps,
actionsMenuBoardRow,
workflowSteps,
showDebugOverlay,
onToggleDebugOverlay,
effectiveSessionId,
remote,
sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
embeddedVscodeSupported: embeddedVscode,
officeTaskHref,
onTaskUnarchived,
});
const layoutProps = buildTaskLayoutProps({
taskProps,
repository,
effectiveSessionId,
initialScripts,
initialTerminals,
defaultLayouts,
merged,
remote,
initialLayout,
onTaskUnarchived,
taskCanvases,
});
return { taskProps, debugEntries, topBarProps, layoutProps };
}
export function TaskPageInner(props: TaskPageInnerProps) {
const { effectiveSessionId, task, merged, sessionPanel, archivedValue, isMobile, ensureSession } =
props;
const [taskMoveError, setTaskMoveError] = useState<unknown>(null);
const clearTaskMoveError = useCallback(() => setTaskMoveError(null), []);
const reportTaskMoveError = useCallback((error: unknown) => setTaskMoveError(error), []);
useEffect(() => {
setTaskMoveError(null);
}, [task?.id]);
const { taskProps, debugEntries, topBarProps, layoutProps } = useTaskPageDerivedProps(props);
if (!task) return null;
return (
<TooltipProvider>
<PortForwardingVisibilityProvider
taskId={taskProps.taskId}
metadata={task?.metadata}
sessionId={effectiveSessionId}
isAgentctlReady={props.agentctlStatus.isReady}
isArchived={taskProps.isArchived}
>
<VcsDialogsProvider
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
pullRequestBaseBranch={taskProps.pullRequestTarget}
pullRequestTargetsByRepository={taskProps.pullRequestTargetsByRepository}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-background">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
isTaskArchived={archivedValue.isArchived}
/>
<TaskPRShortcut taskId={taskProps.taskId} />
<TaskDebugOverlay entries={debugEntries} />
{!isMobile && (
<TaskTopBar
{...topBarProps}
onMoveStart={clearTaskMoveError}
onMoveError={reportTaskMoveError}
/>
)}
{taskMoveError !== null && <TaskMoveErrorBanner error={taskMoveError} />}
{ensureSession.status === "error" && (
<EnsureSessionErrorBanner
error={ensureSession.error}
onRetry={ensureSession.retry}
workspaceId={task?.workspace_id ?? null}
/>
)}
<TaskArchivedProvider value={archivedValue}>
<TaskLaunchErrorProvider
value={{
taskId: task.id,
workspaceId: task.workspace_id,
statusSummary: task.status_summary,
repositories: task.repositories,
automaticRecovery: props.resumption,
}}
>
<TaskPageRecoveryFeedback
taskId={task.id}
sessionId={effectiveSessionId}
resumption={props.resumption}
workspaceId={task?.workspace_id ?? null}
isPassthrough={sessionPanel.isSessionPassthrough}
/>
<TaskLayout {...layoutProps} />
</TaskLaunchErrorProvider>
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</PortForwardingVisibilityProvider>
</TooltipProvider>
);
}