diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx index 06ac32e73e..e2946c57fe 100644 --- a/apps/web/components/task-preview-panel.tsx +++ b/apps/web/components/task-preview-panel.tsx @@ -2,6 +2,7 @@ import { IconArrowsMaximize, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { useAppStore } from "@/components/state-provider"; import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session"; import type { Task } from "./kanban-card"; import { PreviewSessionTabs } from "./task/preview-session-tabs"; @@ -23,6 +24,7 @@ export function TaskPreviewPanel({ onMaximize, onSessionChange, }: TaskPreviewPanelProps) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); return (
) : ( diff --git a/apps/web/components/task/ensure-session-error.test.tsx b/apps/web/components/task/ensure-session-error.test.tsx new file mode 100644 index 0000000000..3268f99f5e --- /dev/null +++ b/apps/web/components/task/ensure-session-error.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})); + +import { + describeEnsureError, + EnsureSessionErrorBanner, + EnsureSessionErrorEmptyState, +} from "./ensure-session-error"; + +afterEach(cleanup); + +describe("describeEnsureError", () => { + it("returns null when there is no error", () => { + expect(describeEnsureError(null)).toBeNull(); + }); + + it("detects the missing-agent-profile case and links to workspace settings", () => { + const info = describeEnsureError( + new Error("agent_profile_id is required to start agent"), + "ws-1", + ); + expect(info).not.toBeNull(); + expect(info?.isAgentProfileMissing).toBe(true); + expect(info?.action).toEqual({ + label: "Open workspace settings", + href: "/settings/workspace/ws-1", + }); + }); + + it("returns a missing-agent-profile descriptor without an action when workspaceId is absent", () => { + const info = describeEnsureError(new Error("agent_profile_id is required to start agent")); + expect(info?.isAgentProfileMissing).toBe(true); + expect(info?.action).toBeNull(); + }); + + it("falls through to the generic error for unrelated failures", () => { + const info = describeEnsureError(new Error("websocket disconnected"), "ws-1"); + expect(info?.isAgentProfileMissing).toBe(false); + expect(info?.title).toBe("Couldn't start a session"); + expect(info?.detail).toContain("websocket disconnected"); + expect(info?.action).toBeNull(); + }); + + it("does not misclassify unrelated errors that merely mention agent_profile_id", () => { + const info = describeEnsureError(new Error("invalid agent_profile_id format"), "ws-1"); + expect(info?.isAgentProfileMissing).toBe(false); + expect(info?.title).toBe("Couldn't start a session"); + }); + + it("uses a fallback detail when the underlying error has no message", () => { + const info = describeEnsureError(new Error("")); + expect(info?.detail).toMatch(/backend rejected/i); + }); +}); + +describe("EnsureSessionErrorBanner", () => { + it("renders nothing when there is no error", () => { + const { container } = render( + {}} workspaceId="ws-1" />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders the missing-agent-profile message and a settings link", () => { + render( + {}} + workspaceId="ws-1" + />, + ); + expect(screen.getByText("No agent profile configured")).toBeTruthy(); + const link = screen.getByTestId("ensure-session-error-action") as HTMLAnchorElement; + expect(link.getAttribute("href")).toBe("/settings/workspace/ws-1"); + }); + + it("invokes onRetry when the retry button is clicked", () => { + const onRetry = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("ensure-session-error-retry")); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); + +describe("EnsureSessionErrorEmptyState", () => { + it("renders the centered preview-ensure-error layout with a retry", () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByTestId("preview-ensure-error")).toBeTruthy(); + fireEvent.click(screen.getByTestId("ensure-session-error-retry")); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/task/ensure-session-error.tsx b/apps/web/components/task/ensure-session-error.tsx new file mode 100644 index 0000000000..c53c63c460 --- /dev/null +++ b/apps/web/components/task/ensure-session-error.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react"; +import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; + +// EnsureSessionErrorInfo wraps a parsed ensure error so UI can offer a targeted action for the missing-agent-profile case. +export type EnsureSessionErrorInfo = { + title: string; + detail: string; + isAgentProfileMissing: boolean; + action: { label: string; href: string } | null; +}; + +// Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go. +const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required"; + +export function describeEnsureError( + error: Error | null, + workspaceId?: string | null, +): EnsureSessionErrorInfo | null { + if (!error) return null; + const message = error.message ?? ""; + const isAgentProfileMissing = message.toLowerCase().includes(AGENT_PROFILE_MISSING_HINT); + if (isAgentProfileMissing) { + return { + title: "No agent profile configured", + detail: + "This task has no agent profile, and the workspace, workflow, and workflow step have no default set. Pick a default agent profile so new tasks can start a session.", + isAgentProfileMissing: true, + action: workspaceId + ? { label: "Open workspace settings", href: `/settings/workspace/${workspaceId}` } + : null, + }; + } + return { + title: "Couldn't start a session", + detail: message || "The backend rejected the session request.", + isAgentProfileMissing: false, + action: null, + }; +} + +type BannerProps = { + error: Error | null; + onRetry: () => void; + workspaceId?: string | null; +}; + +/** Slim banner for the task page, rendered above the layout. */ +export function EnsureSessionErrorBanner({ error, onRetry, workspaceId }: BannerProps) { + const info = describeEnsureError(error, workspaceId); + if (!info) return null; + return ( +
+ + + {info.title} + + {info.detail} + + {info.action ? ( + + {info.action.label} + + ) : null} + + + + +
+ ); +} + +/** Full-panel centered state for the kanban preview's empty-sessions slot. */ +export function EnsureSessionErrorEmptyState({ error, onRetry, workspaceId }: BannerProps) { + const info = describeEnsureError(error, workspaceId); + if (!info) return null; + return ( +
+ {info.title} + {info.detail} + + {info.action ? ( + + {info.action.label} + + ) : null} + + +
+ ); +} diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 1ec72aee85..690197c76b 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Button } from "@kandev/ui/button"; import { AgentLogo } from "@/components/agent-logo"; import { GridSpinner } from "@/components/grid-spinner"; import { PanelLoadingState } from "@/components/panel-loading-state"; @@ -14,6 +13,7 @@ import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ens import type { AgentProfileOption } from "@/lib/state/slices"; import type { TaskSession } from "@/lib/types/http"; import { getWebSocketClient } from "@/lib/ws/connection"; +import { EnsureSessionErrorEmptyState } from "./ensure-session-error"; import { PassthroughToolbar } from "./passthrough-toolbar"; import { TaskChatPanel } from "./task-chat-panel"; import { @@ -30,6 +30,7 @@ type PreviewSessionTabsProps = { taskId: string; sessionId: string | null; ensureSession?: UseEnsureTaskSessionResult; + workspaceId?: string | null; onSessionChange?: (sessionId: string | null) => void; }; @@ -43,6 +44,7 @@ export function PreviewSessionTabs({ taskId, sessionId, ensureSession, + workspaceId, onSessionChange, }: PreviewSessionTabsProps) { const { sessions, isLoaded } = useTaskSessions(taskId); @@ -97,7 +99,13 @@ export function PreviewSessionTabs({ return ; } if (ensureSession?.status === "error") { - return ; + return ( + + ); } return ; } @@ -192,17 +200,3 @@ function PreviewEmptyState() {
); } - -function PreviewEnsureError({ onRetry }: { onRetry: () => void }) { - return ( -
- Failed to prepare workspace. - -
- ); -} diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 05ed70351b..bcff31ac8c 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -1,9 +1,6 @@ "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, @@ -14,8 +11,6 @@ import { } 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"; @@ -27,15 +22,11 @@ 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"; +import { TaskPageInner } from "@/components/task/task-page-inner"; type TaskPageContentProps = { task: Task | null; @@ -105,7 +96,7 @@ function buildTaskFromKanban( }; } -function useWorkflowStepsMapped() { +export function useWorkflowStepsMapped() { const kanbanSteps = useAppStore((state) => state.kanban.steps); return useMemo( () => @@ -124,7 +115,7 @@ function useWorkflowStepsMapped() { ); } -function useSessionPanelState(effectiveSessionId: string | null | undefined) { +export function useSessionPanelState(effectiveSessionId: string | null | undefined) { const storeSessionState = useAppStore((state) => effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.state ?? null) : null, ); @@ -172,7 +163,7 @@ function useSessionPanelState(effectiveSessionId: string | null | undefined) { }; } -function useMergedAgentState( +export function useMergedAgentState( agent: ReturnType, resumption: ReturnType, sessionPanel: ReturnType, @@ -198,257 +189,6 @@ function useMergedAgentState( 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; - activeSessionMetadata?: Record | null; - merged: ReturnType; - resumption: ReturnType; - sessionPanel: ReturnType; - agentctlStatus: ReturnType; -}) { - 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 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 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, - 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; @@ -517,7 +257,7 @@ function useTaskPageData( const { task } = useTaskDetails(activeTaskId, initialTask); const agent = useSessionAgent(task); - useEnsureTaskSession(task); + const ensureSession = useEnsureTaskSession(task); const initialSessionId = sessionId ?? agent.taskSessionId ?? null; const effectiveSessionId = validatedActiveSessionId ?? initialSessionId; @@ -541,7 +281,7 @@ function useTaskPageData( [effectiveRepositories, task?.repositories], ); - return { task, agent, effectiveSessionId, repository }; + return { task, agent, effectiveSessionId, repository, ensureSession }; } export function TaskPageContent({ @@ -560,7 +300,7 @@ export function TaskPageContent({ const { isMobile } = useResponsiveBreakpoint(); const connectionStatus = useAppStore((state) => state.connection.status); - const { task, agent, effectiveSessionId, repository } = useTaskPageData( + const { task, agent, effectiveSessionId, repository, ensureSession } = useTaskPageData( initialTask, initialTaskId, sessionId, @@ -604,6 +344,7 @@ export function TaskPageContent({ defaultLayouts={defaultLayouts} initialLayout={initialLayout} officeTaskHref={officeTaskHref} + ensureSession={ensureSession} /> ); } diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx new file mode 100644 index 0000000000..f1038f13db --- /dev/null +++ b/apps/web/components/task/task-page-inner.tsx @@ -0,0 +1,286 @@ +"use client"; + +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 } from "@/components/task/ensure-session-error"; +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, + buildArchivedValue, + resolveTaskProps, +} from "@/components/task/task-page-content-helpers"; +import type { useSessionAgent } from "@/hooks/domains/session/use-session-agent"; +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"; + +export 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; + ensureSession: UseEnsureTaskSessionResult; +}; + +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), + }; +} + +// 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; + 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; + activeSessionMetadata?: Record | null; + merged: ReturnType; + resumption: ReturnType; + sessionPanel: ReturnType; + agentctlStatus: ReturnType; +}) { + 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, + }); +} + +export function TaskPageInner({ + task, + effectiveSessionId, + repository, + agent, + merged, + resumption, + sessionPanel, + agentctlStatus, + connectionStatus, + workflowSteps, + archivedValue, + isMobile, + showDebugOverlay, + onToggleDebugOverlay, + initialScripts, + initialTerminals, + defaultLayouts, + initialLayout, + officeTaskHref, + ensureSession, +}: TaskPageInnerProps) { + const taskProps = resolveTaskProps(task, repository); + const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null); + 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, + 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 && } + {ensureSession.status === "error" && ( + + )} + + + +
+
+
+ ); +} diff --git a/apps/web/lib/state/dockview-preset-persistence.test.ts b/apps/web/lib/state/dockview-preset-persistence.test.ts new file mode 100644 index 0000000000..0cc87742f3 --- /dev/null +++ b/apps/web/lib/state/dockview-preset-persistence.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { DockviewApi } from "dockview-react"; + +vi.mock("@/lib/local-storage", () => ({ + setEnvLayout: vi.fn(), + getEnvLayout: vi.fn(() => null), + getEnvMaximizeState: vi.fn(() => null), + setEnvMaximizeState: vi.fn(), + removeEnvMaximizeState: vi.fn(), + getGlobalSidebarWidth: vi.fn(() => null), + setGlobalSidebarWidth: vi.fn(), + clearGlobalSidebarWidth: vi.fn(), +})); + +vi.mock("@/lib/layout/panel-portal-manager", () => ({ + panelPortalManager: { releaseByEnv: vi.fn(), reconcile: vi.fn() }, +})); + +vi.mock("./dockview-scroll-preserve", () => ({ + preserveChatScrollDuringLayout: vi.fn(), +})); + +vi.mock("./dockview-measure", () => ({ + measureDockviewContainer: vi.fn(() => ({ width: 800, height: 600 })), +})); + +vi.mock("./dockview-pinned-enforce", () => ({ + enforcePinnedTargets: vi.fn(), +})); + +vi.mock("./dockview-layout-builders", () => ({ + applyLayoutFixups: vi.fn(() => ({ + sidebarGroupId: "g-sidebar", + centerGroupId: "g-center", + rightTopGroupId: "g-right-top", + rightBottomGroupId: "g-right-bottom", + })), + focusOrAddPanel: vi.fn(), +})); + +vi.mock("./layout-manager", () => ({ + SIDEBAR_GROUP: "sidebar", + CENTER_GROUP: "center", + RIGHT_TOP_GROUP: "right-top", + RIGHT_BOTTOM_GROUP: "right-bottom", + TERMINAL_DEFAULT_ID: "terminal", + LAYOUT_SIDEBAR_RATIO: 0.2, + LAYOUT_RIGHT_RATIO: 0.25, + LAYOUT_PINNED_MIN_PX: 200, + computeSidebarMaxPx: vi.fn(() => 350), + computeRightMaxPx: vi.fn(() => 500), + getPresetLayout: vi.fn(() => ({ columns: [] })), + applyLayout: vi.fn(() => ({ + sidebarGroupId: "g-sidebar", + centerGroupId: "g-center", + rightTopGroupId: "g-right-top", + rightBottomGroupId: "g-right-bottom", + })), + getPinnedWidth: vi.fn(() => 350), + getRootSplitview: vi.fn(() => null), + fromDockviewApi: vi.fn(() => ({ columns: [] })), + filterEphemeral: vi.fn((s: unknown) => s), + defaultLayout: vi.fn(() => ({ columns: [] })), + mergeCurrentPanelsIntoPreset: vi.fn((_api: unknown, preset: unknown) => preset), + toSerializedDockview: vi.fn((s: unknown) => s), + injectIntentPanels: vi.fn(), + applyActivePanelOverrides: vi.fn(), + resolveNamedIntent: vi.fn(), + setPinnedTarget: vi.fn(), + clearPinnedTarget: vi.fn(), + getPinnedTarget: vi.fn(() => undefined), + layoutStructuresMatch: vi.fn(() => false), + savedLayoutMatchesLive: vi.fn(() => false), +})); + +import { setEnvLayout } from "@/lib/local-storage"; +import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store"; + +function makeApi(snapshot: object = { columns: [] }): DockviewApi { + return { + toJSON: vi.fn(() => snapshot), + } as unknown as DockviewApi; +} + +function makeStoreApi(): DockviewApi { + return { + width: 800, + height: 600, + panels: [], + groups: [], + layout: vi.fn(), + fromJSON: vi.fn(), + toJSON: vi.fn(() => ({ columns: [{ id: "center" }] })), + getPanel: vi.fn(() => null), + addPanel: vi.fn(), + activeGroup: null, + hasMaximizedGroup: vi.fn(() => false), + exitMaximizedGroup: vi.fn(), + onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })), + } as unknown as DockviewApi; +} + +function flushRaf(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +// Regression: applyBuiltInPreset and applyCustomLayout used to mutate the live +// layout in memory but never wrote it to env-keyed sessionStorage. The auto-save +// in setupLayoutPersistence is gated by isRestoringLayout=true (which both +// actions hold for the entire rAF window in which they emit layout-change +// events), so the debounced save never fired. A page refresh after a preset +// switch restored the pre-preset layout from sessionStorage, losing the user's +// intent. persistEnvLayoutNow runs after isRestoringLayout flips back to false +// at the end of those rAF callbacks. +describe("persistEnvLayoutNow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("writes the live api.toJSON() to env storage when envId is set", () => { + const snapshot = { columns: [{ id: "center" }] }; + const api = makeApi(snapshot); + + persistEnvLayoutNow(api, "env-1", null); + + expect(setEnvLayout).toHaveBeenCalledTimes(1); + expect(setEnvLayout).toHaveBeenCalledWith("env-1", snapshot); + }); + + it("is a no-op when envId is null (no env adopted yet)", () => { + const api = makeApi(); + + persistEnvLayoutNow(api, null, null); + + expect(setEnvLayout).not.toHaveBeenCalled(); + expect(api.toJSON).not.toHaveBeenCalled(); + }); + + it("is a no-op while maximized (toJSON would be the 2-column overlay)", () => { + // saveOutgoingEnv owns the maximize slot. Writing the overlay as the env's + // regular layout would resurface a truncated layout on reload. + const api = makeApi(); + const preMaximizeLayout = { columns: [] }; + + persistEnvLayoutNow(api, "env-1", preMaximizeLayout); + + expect(setEnvLayout).not.toHaveBeenCalled(); + expect(api.toJSON).not.toHaveBeenCalled(); + }); + + it("swallows serialization/storage errors so callers do not crash mid-rAF", () => { + const api = { + toJSON: vi.fn(() => { + throw new Error("dockview serialize failed"); + }), + } as unknown as DockviewApi; + + expect(() => persistEnvLayoutNow(api, "env-1", null)).not.toThrow(); + expect(setEnvLayout).not.toHaveBeenCalled(); + }); +}); + +// Integration coverage: assert the real preset/custom-layout actions invoke +// persistEnvLayoutNow at the end of their rAF callback. The helper-only tests +// above cover the contract, but the bug we fixed was at the call sites — they +// previously held isRestoringLayout=true for the whole rAF and never wrote. +function resetStoreForIntegration() { + vi.clearAllMocks(); + useDockviewStore.setState({ + api: null, + currentLayoutEnvId: null, + preMaximizeLayout: null, + maximizedGroupId: null, + isRestoringLayout: false, + }); +} + +const customLayout = { + id: "custom-1", + name: "custom", + layout: { columns: [{ id: "center", views: [], activeView: null }] }, +}; +type ApplyCustomLayoutArg = Parameters< + ReturnType["applyCustomLayout"] +>[0]; + +describe("applyBuiltInPreset — persistence at call site", () => { + beforeEach(resetStoreForIntegration); + + it("persists the env layout after isRestoringLayout clears", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-preset" }); + + useDockviewStore.getState().applyBuiltInPreset("default"); + + expect(useDockviewStore.getState().isRestoringLayout).toBe(true); + await flushRaf(); + + expect(useDockviewStore.getState().isRestoringLayout).toBe(false); + expect(setEnvLayout).toHaveBeenCalledTimes(1); + expect(setEnvLayout).toHaveBeenCalledWith("env-preset", expect.any(Object)); + }); + + it("does not persist when no env is adopted yet", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: null }); + + useDockviewStore.getState().applyBuiltInPreset("default"); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("skips persistence while maximized to avoid stomping the regular layout", async () => { + const api = makeStoreApi(); + type StoreState = ReturnType; + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-maxed", + preMaximizeLayout: { columns: [] } as unknown as StoreState["preMaximizeLayout"], + }); + + useDockviewStore.getState().applyBuiltInPreset("default"); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("does not persist if the env changes between scheduling and rAF", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-before" }); + + useDockviewStore.getState().applyBuiltInPreset("default"); + // Simulate the user navigating to a different task in the ~16ms before + // the rAF callback fires. The rAF should detect the env switch and skip + // the write so the old layout is not stored under the new env's key. + useDockviewStore.setState({ currentLayoutEnvId: "env-after" }); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); +}); + +describe("applyCustomLayout — persistence at call site", () => { + beforeEach(resetStoreForIntegration); + + it("persists the env layout after isRestoringLayout clears", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-custom" }); + + useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg); + await flushRaf(); + + expect(setEnvLayout).toHaveBeenCalledTimes(1); + expect(setEnvLayout).toHaveBeenCalledWith("env-custom", expect.any(Object)); + }); + + it("does not persist when no env is adopted yet", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: null }); + + useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("skips persistence while maximized", async () => { + const api = makeStoreApi(); + type StoreState = ReturnType; + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-maxed-custom", + preMaximizeLayout: { columns: [] } as unknown as StoreState["preMaximizeLayout"], + }); + + useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("does not persist if the env changes between scheduling and rAF", async () => { + const api = makeStoreApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-before-custom" }); + + useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg); + useDockviewStore.setState({ currentLayoutEnvId: "env-after-custom" }); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("does not persist when legacy fromJSON restore throws", async () => { + const api = makeStoreApi(); + // Force the old-format path by passing a layout without `columns`, and + // make fromJSON throw so the API may be in a partial state. Persisting + // that partial snapshot would propagate corruption to the next load. + (api.fromJSON as ReturnType).mockImplementation(() => { + throw new Error("dockview fromJSON failed"); + }); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-legacy" }); + + const legacyLayout = { id: "legacy", name: "legacy", layout: { grid: {} } }; + useDockviewStore.getState().applyCustomLayout(legacyLayout as unknown as ApplyCustomLayoutArg); + await flushRaf(); + + expect(setEnvLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index aaaa42a2a4..1f45d8cd20 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -497,6 +497,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { rightPanelsVisible: preset === "default", pinnedWidths: cleanedWidths, }); + const targetEnvId = get().currentLayoutEnvId; requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); if (isDebug()) { @@ -507,6 +508,10 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { enforceFromStore(api, get); syncPinnedWidthsFromApi(api, set); set({ isRestoringLayout: false }); + const { currentLayoutEnvId, preMaximizeLayout } = get(); + if (currentLayoutEnvId === targetEnvId) { + persistEnvLayoutNow(api, targetEnvId, preMaximizeLayout); + } }); }, applyCustomLayout: (layout: SavedLayoutConfig) => { @@ -517,12 +522,14 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true }); const state = layout.layout as unknown as LayoutState; + let oldFormatRestoreFailed = false; if (!state?.columns) { try { api.fromJSON(layout.layout as unknown as SerializedDockview); set(applyLayoutFixups(api)); } catch (e) { console.warn("applyCustomLayout: old-format restore failed:", e); + oldFormatRestoreFailed = true; } } else { const ids = applyLayout(api, state, liveWidths, safeWidth, safeHeight); @@ -533,11 +540,17 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const sidebarCols = hasSidebar ? 1 : 0; const hasRight = colCount > sidebarCols + 1; set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); + const targetEnvId = get().currentLayoutEnvId; requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); enforceFromStore(api, get); syncPinnedWidthsFromApi(api, set); set({ isRestoringLayout: false }); + const { currentLayoutEnvId, preMaximizeLayout } = get(); + // Don't persist when the legacy fromJSON restore threw: the API may be in a partial state and snapshotting it would propagate corruption to the next load. + if (currentLayoutEnvId === targetEnvId && !oldFormatRestoreFailed) { + persistEnvLayoutNow(api, targetEnvId, preMaximizeLayout); + } }); }, captureCurrentLayout: (): Record => { @@ -582,6 +595,22 @@ function restoreMaximizeFromStorage(api: DockviewApi, envId: string, set: StoreS return true; } +// Persist settled layout to env storage; auto-save in setupLayoutPersistence is gated by isRestoringLayout, so preset/custom actions must call this after the flag clears. +export function persistEnvLayoutNow( + api: DockviewApi, + envId: string | null, + preMaximizeLayout: LayoutState | null, +): void { + if (!envId) return; + // While maximized, api.toJSON() is the 2-column overlay; the regular layout has its own slot via saveOutgoingEnv. + if (preMaximizeLayout !== null) return; + try { + setEnvLayout(envId, api.toJSON()); + } catch { + /* ignore serialization/storage failures */ + } +} + /** Save the outgoing env's layout & maximize state, then release its portals. */ function saveOutgoingEnv( api: DockviewApi,