feat(web): surface ensure-session errors and persist preset layout changes (#1394)

This commit is contained in:
Carlos Florêncio
2026-06-16 15:21:20 +01:00
committed by GitHub
parent 179ba51ccb
commit 3bac35b574
8 changed files with 877 additions and 283 deletions
@@ -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 (
<div
data-testid="task-preview-panel"
@@ -58,6 +60,7 @@ export function TaskPreviewPanel({
taskId={task.id}
sessionId={sessionId}
ensureSession={ensureSession}
workspaceId={activeWorkspaceId ?? null}
onSessionChange={onSessionChange}
/>
) : (
@@ -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 }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
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(
<EnsureSessionErrorBanner error={null} onRetry={() => {}} workspaceId="ws-1" />,
);
expect(container.firstChild).toBeNull();
});
it("renders the missing-agent-profile message and a settings link", () => {
render(
<EnsureSessionErrorBanner
error={new Error("agent_profile_id is required to start agent")}
onRetry={() => {}}
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(
<EnsureSessionErrorBanner error={new Error("websocket disconnected")} onRetry={onRetry} />,
);
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(
<EnsureSessionErrorEmptyState
error={new Error("boom")}
onRetry={onRetry}
workspaceId={null}
/>,
);
expect(screen.getByTestId("preview-ensure-error")).toBeTruthy();
fireEvent.click(screen.getByTestId("ensure-session-error-retry"));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -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 (
<div className="px-3 pt-2" data-testid="ensure-session-error-banner">
<Alert variant="destructive">
<IconAlertTriangle />
<AlertTitle>{info.title}</AlertTitle>
<AlertDescription>
<span>{info.detail}</span>
<span className="mt-1 flex flex-wrap items-center gap-2">
{info.action ? (
<Link
href={info.action.href}
className="cursor-pointer underline underline-offset-2 hover:text-foreground"
data-testid="ensure-session-error-action"
>
{info.action.label}
</Link>
) : null}
<Button
variant="outline"
size="sm"
className="h-6 cursor-pointer px-2 text-xs"
onClick={onRetry}
data-testid="ensure-session-error-retry"
>
<IconRefresh className="size-3" />
Retry
</Button>
</span>
</AlertDescription>
</Alert>
</div>
);
}
/** 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 (
<div
className="flex h-full flex-col items-center justify-center gap-3 px-4 text-center text-sm"
data-testid="preview-ensure-error"
>
<span className="font-medium text-foreground">{info.title}</span>
<span className="max-w-xs text-muted-foreground">{info.detail}</span>
<span className="flex flex-wrap items-center justify-center gap-2">
{info.action ? (
<Link
href={info.action.href}
className="cursor-pointer underline underline-offset-2 hover:text-foreground"
data-testid="ensure-session-error-action"
>
{info.action.label}
</Link>
) : null}
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={onRetry}
data-testid="ensure-session-error-retry"
>
Retry
</Button>
</span>
</div>
);
}
@@ -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 <PreviewLoadingState label="Preparing workspace…" />;
}
if (ensureSession?.status === "error") {
return <PreviewEnsureError onRetry={ensureSession.retry} />;
return (
<EnsureSessionErrorEmptyState
error={ensureSession.error}
onRetry={ensureSession.retry}
workspaceId={workspaceId ?? null}
/>
);
}
return <PreviewEmptyState />;
}
@@ -192,17 +200,3 @@ function PreviewEmptyState() {
</div>
);
}
function PreviewEnsureError({ onRetry }: { onRetry: () => void }) {
return (
<div
className="flex h-full flex-col items-center justify-center gap-3 text-sm"
data-testid="preview-ensure-error"
>
<span className="text-muted-foreground">Failed to prepare workspace.</span>
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onRetry}>
Retry
</Button>
</div>
);
}
+8 -267
View File
@@ -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<typeof useSessionAgent>,
resumption: ReturnType<typeof useSessionResumption>,
sessionPanel: ReturnType<typeof useSessionPanelState>,
@@ -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<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
resumption: ReturnType<typeof useSessionResumption>;
sessionPanel: ReturnType<typeof useSessionPanelState>;
agentctlStatus: ReturnType<typeof useSessionAgentctl>;
connectionStatus: string;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
archivedValue: ReturnType<typeof buildArchivedValue>;
isMobile: boolean;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
initialLayout?: string | null;
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<typeof resolveTaskProps>;
agent: ReturnType<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
agentctlReady: boolean;
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<typeof resolveTaskProps>;
repository: Repository | null;
effectiveSessionId: string | null;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
merged: ReturnType<typeof useMergedAgentState>;
remote: ReturnType<typeof resolveRemoteExecutor>;
initialLayout?: string | null;
}) {
const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params;
return {
workspaceId: taskProps.workspaceId,
workflowId: taskProps.workflowId,
sessionId: effectiveSessionId,
repository: repository ?? null,
initialScripts,
initialTerminals,
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskTitle: taskProps.taskTitle,
baseBranch: taskProps.baseBranch,
worktreeBranch: params.merged.worktreeBranch,
isRemoteExecutor: params.remote.isRemoteExecutor,
remoteExecutorType: params.remote.remoteExecutorType,
remoteExecutorName: params.remote.remoteExecutorName,
remoteState: params.remote.remoteState,
remoteCreatedAt: params.remote.remoteCreatedAt,
remoteCheckedAt: params.remote.remoteCheckedAt,
remoteStatusError: params.remote.remoteStatusError,
};
}
function maybeBuildDebugEntries(params: {
isVisible: boolean;
connectionStatus: string;
task: Task | null;
effectiveSessionId: string | null | undefined;
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 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 (
<TooltipProvider>
<VcsDialogsProvider
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="h-screen w-full flex flex-col bg-background overflow-hidden">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
/>
{debugEntries && <DebugOverlay title="Task Debug" entries={debugEntries} />}
{!isMobile && <TaskTopBar {...topBarProps} />}
<TaskArchivedProvider value={archivedValue}>
<TaskLayout {...layoutProps} />
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</TooltipProvider>
);
}
function syncActiveTaskSession(params: {
initialTaskId: string | undefined;
fallbackTaskId: string | null | undefined;
@@ -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}
/>
);
}
@@ -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<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
resumption: ReturnType<typeof useSessionResumption>;
sessionPanel: ReturnType<typeof useSessionPanelState>;
agentctlStatus: ReturnType<typeof useSessionAgentctl>;
connectionStatus: string;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
archivedValue: ReturnType<typeof buildArchivedValue>;
isMobile: boolean;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
initialLayout?: string | null;
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<typeof resolveTaskProps>;
agent: ReturnType<typeof useSessionAgent>;
merged: ReturnType<typeof useMergedAgentState>;
workflowSteps: ReturnType<typeof useWorkflowStepsMapped>;
showDebugOverlay: boolean;
onToggleDebugOverlay: () => void;
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
agentctlReady: boolean;
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<typeof resolveTaskProps>;
repository: Repository | null;
effectiveSessionId: string | null;
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
merged: ReturnType<typeof useMergedAgentState>;
remote: ReturnType<typeof resolveRemoteExecutor>;
initialLayout?: string | null;
}) {
const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params;
return {
workspaceId: taskProps.workspaceId,
workflowId: taskProps.workflowId,
sessionId: effectiveSessionId,
repository: repository ?? null,
initialScripts,
initialTerminals,
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskTitle: taskProps.taskTitle,
baseBranch: taskProps.baseBranch,
worktreeBranch: params.merged.worktreeBranch,
isRemoteExecutor: params.remote.isRemoteExecutor,
remoteExecutorType: params.remote.remoteExecutorType,
remoteExecutorName: params.remote.remoteExecutorName,
remoteState: params.remote.remoteState,
remoteCreatedAt: params.remote.remoteCreatedAt,
remoteCheckedAt: params.remote.remoteCheckedAt,
remoteStatusError: params.remote.remoteStatusError,
};
}
function maybeBuildDebugEntries(params: {
isVisible: boolean;
connectionStatus: string;
task: Task | null;
effectiveSessionId: string | null | undefined;
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,
});
}
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 (
<TooltipProvider>
<VcsDialogsProvider
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="h-screen w-full flex flex-col bg-background overflow-hidden">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
/>
{debugEntries && <DebugOverlay title="Task Debug" entries={debugEntries} />}
{!isMobile && <TaskTopBar {...topBarProps} />}
{ensureSession.status === "error" && (
<EnsureSessionErrorBanner
error={ensureSession.error}
onRetry={ensureSession.retry}
workspaceId={task?.workspace_id ?? null}
/>
)}
<TaskArchivedProvider value={archivedValue}>
<TaskLayout {...layoutProps} />
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</TooltipProvider>
);
}
@@ -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<void> {
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<typeof useDockviewStore.getState>["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<typeof useDockviewStore.getState>;
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<typeof useDockviewStore.getState>;
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<typeof vi.fn>).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();
});
});
+29
View File
@@ -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<string, unknown> => {
@@ -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,