feat(web): surface ensure-session errors and persist preset layout changes (#1394)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user