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,