fix: sidebar task switcher shows outdated session state and custom maximize layout (#345)

This commit is contained in:
Carlos Florêncio
2026-03-02 14:12:11 +00:00
committed by GitHub
parent 8b5681bee2
commit 76c1dcce89
12 changed files with 673 additions and 149 deletions
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useRef, useState, memo } from "react";
import React, { useCallback, useEffect, useRef, memo } from "react";
import {
DockviewReact,
DockviewDefaultTab,
@@ -12,7 +12,7 @@ import {
import { themeKandev } from "@/lib/layout/dockview-theme";
import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store";
import { applyLayoutFixups, getRootSplitview } from "@/lib/state/dockview-layout-builders";
import { getSessionLayout, setSessionLayout } from "@/lib/local-storage";
import { getSessionLayout, setSessionLayout, getSessionMaximizeState } from "@/lib/local-storage";
import { useAppStore } from "@/components/state-provider";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useLspFileOpener } from "@/hooks/use-lsp-file-opener";
@@ -40,14 +40,9 @@ import { CommitDetailPanel } from "./commit-detail-panel";
import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel";
import { PreviewController } from "./preview/preview-controller";
import { ReviewDialog } from "@/components/review/review-dialog";
import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
import { formatReviewCommentsAsMarkdown } from "@/components/task/chat/messages/review-comments-attachment";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useToast } from "@/components/toast-provider";
import type { DiffComment } from "@/lib/diff/types";
import { stopUserShell } from "@/lib/api/domains/user-shell-api";
import { useReviewDialog } from "./use-review-dialog";
import type { Repository, RepositoryScript } from "@/lib/types/http";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
@@ -397,6 +392,46 @@ function sanitizeLayout(layout: any): any {
};
}
/** Restore maximize store state alongside layout fixups if saved maximize data exists. */
function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], sessionId: string | null): void {
const savedMax = sessionId ? getSessionMaximizeState(sessionId) : null;
if (savedMax) {
// Restore the actual maximized dockview layout (2-panel: sidebar + maximized content),
// not just the store state. Without this, the store thinks it's maximized but dockview
// still shows the normal 5-panel layout.
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
api.layout(api.width, api.height);
const ids = applyLayoutFixups(api);
type LM = import("@/lib/state/layout-manager").LayoutState;
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM,
});
} else {
const ids = applyLayoutFixups(api);
useDockviewStore.setState(ids);
}
}
/** Try restoring maximize-only state (no full session layout saved). */
function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], sessionId: string): boolean {
const savedMax = getSessionMaximizeState(sessionId);
if (!savedMax) return false;
try {
api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview);
api.layout(api.width, api.height);
const ids = applyLayoutFixups(api);
type LM = import("@/lib/state/layout-manager").LayoutState;
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM,
});
return true;
} catch {
return false;
}
}
function tryRestoreLayout(
api: DockviewReadyEvent["api"],
currentSessionId: string | null,
@@ -408,12 +443,16 @@ function tryRestoreLayout(
const sanitized = sanitizeLayout(sessionLayout);
if (!sanitized) return false;
api.fromJSON(sanitized as SerializedDockview);
useDockviewStore.setState(applyLayoutFixups(api));
applyFixupsWithMaximize(api, currentSessionId);
return true;
}
} catch {
// Per-session restore failed, try global
}
// No saved session layout, but maximize state may exist
// (maximize is saved synchronously, layout persistence is debounced and may not
// have fired before refresh). Restore the maximized dockview layout directly.
if (tryRestoreMaximizeOnly(api, currentSessionId)) return true;
}
if (!currentSessionId) {
@@ -436,6 +475,7 @@ function tryRestoreLayout(
function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
if (useDockviewStore.getState().isRestoringLayout) return;
if (api.hasMaximizedGroup() || useDockviewStore.getState().preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
@@ -467,17 +507,17 @@ function setupChatPanelSafetyNet(api: DockviewReadyEvent["api"]) {
if (useDockviewStore.getState().isRestoringLayout) return;
requestAnimationFrame(() => {
if (api.getPanel("chat")) return;
const sidebarPanel = api.getPanel("sidebar");
const sb = api.getPanel("sidebar");
api.addPanel({
id: "chat",
component: "chat",
tabComponent: "permanentTab",
title: "Agent",
position: sidebarPanel ? { direction: "right", referencePanel: "sidebar" } : undefined,
position: sb ? { direction: "right", referencePanel: "sidebar" } : undefined,
});
const newChat = api.getPanel("chat");
if (newChat) {
useDockviewStore.setState({ centerGroupId: newChat.group.id });
const nc = api.getPanel("chat");
if (nc) {
useDockviewStore.setState({ centerGroupId: nc.group.id });
}
});
});
@@ -512,82 +552,48 @@ function setupLayoutPersistence(
* closed a tab), but NOT during layout restores where fromJSON temporarily
* removes all panels.
*/
function setupGroupTracking(api: DockviewReadyEvent["api"]) {
api.onDidActiveGroupChange((group) => {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
api.onDidLayoutChange(() => trackPinnedWidths(api));
trackPinnedWidths(api);
}
function setupPortalCleanup(api: DockviewReadyEvent["api"]) {
api.onDidRemovePanel((panel) => {
if (useDockviewStore.getState().isRestoringLayout) return;
// Stop code-server when the user explicitly closes the vscode tab
const isMax = useDockviewStore.getState().preMaximizeLayout !== null;
const remaining = api.panels.filter((p) => p.id !== panel.id);
const nonSidebar = remaining.filter((p) => p.api.component !== "sidebar");
// If we're in maximize mode and the last non-sidebar panel was just closed,
// exit maximize to restore the pre-maximize layout (avoids empty view).
// Then remove the closed panel from the restored layout so it doesn't reappear.
if (isMax && nonSidebar.length === 0) {
const removedId = panel.id;
requestAnimationFrame(() => {
useDockviewStore.getState().exitMaximizedLayout();
// exitMaximizedLayout schedules a rAF to finalize — wait for that, then
// remove the panel that was closed (it was re-created from preMaximizeLayout).
requestAnimationFrame(() => {
const restoredPanel = api.getPanel(removedId);
if (restoredPanel) {
restoredPanel.api.close();
}
});
});
}
const entry = panelPortalManager.get(panel.id);
if (entry?.component === "vscode" && entry.sessionId) {
stopVscode(entry.sessionId);
if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId);
if (entry?.component === "terminal" && entry.sessionId) {
const terminalId = entry.params.terminalId as string | undefined;
if (terminalId) stopUserShell(entry.sessionId, terminalId);
}
panelPortalManager.release(panel.id);
});
}
// ---------------------------------------------------------------------------
// useReviewDialog hook
// ---------------------------------------------------------------------------
function useReviewDialog(effectiveSessionId: string | null) {
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const { toast } = useToast();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const baseBranch = useAppStore((state) => {
if (!effectiveSessionId) return undefined;
return state.taskSessions.items[effectiveSessionId]?.base_branch;
});
const reviewGitStatus = useSessionGitStatus(effectiveSessionId);
const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId);
const { openFile: reviewOpenFile } = useFileEditors();
const reviewTaskPR = useActiveTaskPR();
const { files: reviewPRDiffFiles } = usePRDiff(
reviewTaskPR?.owner ?? null,
reviewTaskPR?.repo ?? null,
reviewTaskPR?.pr_number ?? null,
);
const handleReviewSendComments = useCallback(
(comments: DiffComment[]) => {
if (!activeTaskId || !effectiveSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const markdown = formatReviewCommentsAsMarkdown(comments);
client
.request(
"message.add",
{
task_id: activeTaskId,
session_id: effectiveSessionId,
content: markdown,
},
10000,
)
.catch(() => {
toast({ title: "Failed to send comments", variant: "error" });
});
setReviewDialogOpen(false);
},
[activeTaskId, effectiveSessionId, toast],
);
useEffect(() => {
const handler = () => setReviewDialogOpen(true);
window.addEventListener("open-review-dialog", handler);
return () => window.removeEventListener("open-review-dialog", handler);
}, []);
return {
reviewDialogOpen,
setReviewDialogOpen,
baseBranch,
reviewGitStatus,
reviewCumulativeDiff,
reviewPRDiffFiles,
reviewOpenFile,
handleReviewSendComments,
};
}
// ---------------------------------------------------------------------------
// useSessionSwitchCleanup — releases session-scoped portals + triggers layout switch
// ---------------------------------------------------------------------------
@@ -638,10 +644,10 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const setApi = useDockviewStore((s) => s.setApi);
const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionIdRef = useRef<string | null>(null);
const effectiveSessionId =
useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
const sessionIdRef = useRef<string | null>(effectiveSessionId);
const hasDevScript = Boolean(repository?.dev_script?.trim());
const review = useReviewDialog(effectiveSessionId);
@@ -682,16 +688,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
useDockviewStore.setState({ currentLayoutSessionId: currentSessionId });
// Track active group
api.onDidActiveGroupChange((group) => {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
// Track pinned column widths on layout changes (captures user sash drags)
api.onDidLayoutChange(() => trackPinnedWidths(api));
trackPinnedWidths(api);
setupGroupTracking(api);
setupChatPanelSafetyNet(api);
setupLayoutPersistence(api, saveTimerRef, sessionIdRef);
setupPortalCleanup(api);
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { type IDockviewHeaderActionsProps } from "dockview-react";
import {
IconPlus,
@@ -163,15 +163,17 @@ const ACTION_BTN =
function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) {
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
const isChatGroup = group.id === centerGroupId;
const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null);
const storeMaximize = useDockviewStore((s) => s.maximizeGroup);
const storeExitMaximize = useDockviewStore((s) => s.exitMaximizedLayout);
const handleMaximize = useCallback(() => {
if (containerApi.hasMaximizedGroup()) {
containerApi.exitMaximizedGroup();
if (isMaximized) {
storeExitMaximize();
} else {
const panel = group.activePanel;
if (panel) containerApi.maximizeGroup(panel);
storeMaximize(group.id);
}
}, [group, containerApi]);
}, [group.id, isMaximized, storeMaximize, storeExitMaximize]);
const handleSplitRight = useCallback(() => {
containerApi.addGroup({ referenceGroup: group, direction: "right" });
@@ -200,19 +202,16 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
}
}, [group, containerApi]);
const [isMaximized, setIsMaximized] = useState(() => containerApi.hasMaximizedGroup());
useEffect(() => {
const disposable = containerApi.onDidMaximizedGroupChange(() => {
setIsMaximized(containerApi.hasMaximizedGroup());
});
return () => disposable.dispose();
}, [containerApi]);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className={ACTION_BTN} onClick={handleMaximize}>
<button
type="button"
className={ACTION_BTN}
onClick={handleMaximize}
data-testid="dockview-maximize-btn"
>
{isMaximized ? (
<IconArrowsMinimize className="h-3 w-3" />
) : (
@@ -241,7 +240,12 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
{!isChatGroup && (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className={ACTION_BTN} onClick={handleCloseGroup}>
<button
type="button"
className={ACTION_BTN}
onClick={handleCloseGroup}
data-testid="dockview-close-group-btn"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
@@ -98,6 +98,17 @@ function initTerminalInstance(
}
refs.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon;
// Expose buffer reader on the container for e2e tests (xterm renders to
// canvas so text isn't accessible in the DOM).
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
() => {
const buf = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
lines.push(buf.getLine(i)?.translateToString(true) ?? "");
}
return lines.join("\n");
};
const handleResize = () => {
const rect = termContainer.getBoundingClientRect();
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
@@ -120,6 +131,8 @@ function initTerminalInstance(
refs.webglAddonRef.current = null;
}
terminal.dispose();
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
undefined;
refs.xtermRef.current = null;
refs.fitAddonRef.current = null;
refs.isInitializedRef.current = false;
@@ -131,12 +131,13 @@ function useSidebarData(workspaceId: string | null) {
);
const items = allTasks.map((task: KanbanState["tasks"][number]) => {
const sessionInfo = getSessionInfoForTask(task.id, sessionsByTaskId, gitStatusBySessionId);
const resolvedSessionState =
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined);
return {
id: task.id,
title: task.title,
state: task.state as TaskState | undefined,
sessionState:
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined),
sessionState: resolvedSessionState,
description: task.description,
workflowStepId: task.workflowStepId as string | undefined,
repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined,
@@ -0,0 +1,69 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
import { formatReviewCommentsAsMarkdown } from "@/components/task/chat/messages/review-comments-attachment";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useToast } from "@/components/toast-provider";
import type { DiffComment } from "@/lib/diff/types";
export function useReviewDialog(effectiveSessionId: string | null) {
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const { toast } = useToast();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const baseBranch = useAppStore((state) => {
if (!effectiveSessionId) return undefined;
return state.taskSessions.items[effectiveSessionId]?.base_branch;
});
const reviewGitStatus = useSessionGitStatus(effectiveSessionId);
const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId);
const { openFile: reviewOpenFile } = useFileEditors();
const reviewTaskPR = useActiveTaskPR();
const { files: reviewPRDiffFiles } = usePRDiff(
reviewTaskPR?.owner ?? null,
reviewTaskPR?.repo ?? null,
reviewTaskPR?.pr_number ?? null,
);
const handleReviewSendComments = useCallback(
(comments: DiffComment[]) => {
if (!activeTaskId || !effectiveSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const markdown = formatReviewCommentsAsMarkdown(comments);
client
.request(
"message.add",
{ task_id: activeTaskId, session_id: effectiveSessionId, content: markdown },
10000,
)
.catch(() => {
toast({ title: "Failed to send comments", variant: "error" });
});
setReviewDialogOpen(false);
},
[activeTaskId, effectiveSessionId, toast],
);
useEffect(() => {
const handler = () => setReviewDialogOpen(true);
window.addEventListener("open-review-dialog", handler);
return () => window.removeEventListener("open-review-dialog", handler);
}, []);
return {
reviewDialogOpen,
setReviewDialogOpen,
baseBranch,
reviewGitStatus,
reviewCumulativeDiff,
reviewPRDiffFiles,
reviewOpenFile,
handleReviewSendComments,
};
}
+8
View File
@@ -12,6 +12,7 @@ export type SeedData = {
startStepId: string;
steps: WorkflowStep[];
repositoryId: string;
agentProfileId: string;
};
export const test = backendFixture.extend<
@@ -56,12 +57,19 @@ export const test = backendFixture.extend<
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
const repo = await apiClient.createRepository(workspace.id, repoDir);
const { agents } = await apiClient.listAgents();
const agentProfileId = agents[0]?.profiles[0]?.id;
if (!agentProfileId) {
throw new Error("E2E seed failed: no agent profile available");
}
await use({
workspaceId: workspace.id,
workflowId: workflow.id,
startStepId: startStep.id,
steps: sorted,
repositoryId: repo.id,
agentProfileId,
});
},
{ scope: "worker" },
+85 -1
View File
@@ -1,4 +1,4 @@
import { type Locator, type Page } from "@playwright/test";
import { type Locator, type Page, expect } from "@playwright/test";
export class SessionPage {
readonly chat: Locator;
@@ -83,4 +83,88 @@ export class SessionPage {
await editor.click();
await editor.press("Shift+Tab");
}
/**
* Wait for the terminal shell to be connected (buffer has content from
* the prompt), then type a command and press Enter.
*/
async typeInTerminal(command: string): Promise<void> {
await expect
.poll(
async () => {
return this.page.evaluate(() => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
const xtermEl = panel?.querySelector(".xterm");
type XC = HTMLElement & { __xtermReadBuffer?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
const buf = container?.__xtermReadBuffer?.();
return (buf?.length ?? 0) > 0;
});
},
{ timeout: 15_000, message: "Waiting for terminal shell to connect" },
)
.toBe(true);
const xterm = this.terminal.locator(".xterm");
await xterm.click();
await this.page.keyboard.type(command);
await this.page.keyboard.press("Enter");
}
/**
* Assert the terminal buffer contains the given text.
* xterm renders to canvas/WebGL so text isn't in the DOM. Uses the
* __xtermReadBuffer() helper exposed on the terminal container element.
*/
async expectTerminalHasText(text: string): Promise<void> {
await expect
.poll(
async () => {
return this.page.evaluate((marker) => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
if (!panel) return false;
const xtermEl = panel.querySelector(".xterm");
type XC = HTMLElement & { __xtermReadBuffer?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
const buf = container?.__xtermReadBuffer?.();
return buf?.includes(marker) ?? false;
}, text);
},
{ timeout: 10_000, message: `Expected terminal to contain "${text}"` },
)
.toBe(true);
}
/**
* Click the maximize button on the dockview group that contains a tab
* with the given name. Defaults to "Terminal".
*/
async clickMaximize(tabName = "Terminal"): Promise<void> {
const header = this.page.locator(
`.dv-tabs-and-actions-container:has(.dv-default-tab:has-text('${tabName}'))`,
);
await header.getByTestId("dockview-maximize-btn").click();
}
/**
* Assert the layout is in maximized state: terminal visible,
* sidebar visible (UI: |sidebar|maximized-group|), chat and files hidden.
*/
async expectMaximized(): Promise<void> {
await expect(this.terminal).toBeVisible({ timeout: 10_000 });
await expect(this.sidebar).toBeVisible();
await expect(this.chat).not.toBeVisible({ timeout: 5_000 });
await expect(this.files).not.toBeVisible({ timeout: 5_000 });
}
/**
* Assert the layout is in the default (non-maximized) state:
* chat, terminal, files, and sidebar are all visible.
*/
async expectDefaultLayout(): Promise<void> {
await expect(this.chat).toBeVisible({ timeout: 10_000 });
await expect(this.terminal).toBeVisible({ timeout: 10_000 });
await expect(this.files).toBeVisible({ timeout: 10_000 });
await expect(this.sidebar).toBeVisible();
}
}
@@ -0,0 +1,159 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
/**
* Seed a task + session via the API and navigate directly to the session page.
* Waits for the mock agent to complete its turn (idle input visible).
*/
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
description = "/e2e:simple-message",
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/s/${task.session_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return session;
}
const TERMINAL_MARKER = "KANDEV_E2E_MARKER_12345";
test.describe("Session details UX", () => {
// The standalone executor can fail to allocate a port on cold start;
// retry once so a transient backend hiccup doesn't fail the suite.
test.describe.configure({ retries: 1 });
test("maximize terminal hides other panels", async ({ testPage, apiClient, seedData }) => {
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Maximize Test");
// Default layout: all panels visible
await session.expectDefaultLayout();
// Type a command in the terminal
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
await session.expectTerminalHasText(TERMINAL_MARKER);
// Maximize the terminal group
await session.clickMaximize();
// Only terminal and sidebar should be visible, with our output
await session.expectMaximized();
await session.expectTerminalHasText(TERMINAL_MARKER);
});
test("maximize survives page refresh", async ({ testPage, apiClient, seedData }) => {
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Refresh Test");
// Type a command in the terminal, then maximize
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
await session.expectTerminalHasText(TERMINAL_MARKER);
await session.clickMaximize();
await session.expectMaximized();
// Refresh the page — maximize state is saved in sessionStorage
await testPage.reload();
// After refresh: terminal should still be maximized
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
await session.expectMaximized();
// Terminal reconnects to the same shell — our output should still be there
await session.expectTerminalHasText(TERMINAL_MARKER);
});
test("task switching preserves maximize per session", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Create Task A, type a command, and maximize terminal
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Task A Maximize");
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
await session.expectTerminalHasText(TERMINAL_MARKER);
await session.clickMaximize();
await session.expectMaximized();
// Remember Task A's URL so we can navigate back after visiting Task B
const taskAUrl = testPage.url();
// Create Task B via API and navigate directly
const taskB = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Task B Normal",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!taskB.session_id) throw new Error("Task B creation did not return session_id");
await testPage.goto(`/s/${taskB.session_id}`);
const sessionB = new SessionPage(testPage);
await sessionB.waitForLoad();
await expect(sessionB.idleInput()).toBeVisible({ timeout: 30_000 });
// Task B should have default (non-maximized) layout
await sessionB.expectDefaultLayout();
// Navigate back to Task A's session URL — this triggers a full page load,
// exercising the tryRestoreLayout path that restores maximize from sessionStorage.
await testPage.goto(taskAUrl);
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
// Task A should still be maximized with our output
await session.expectMaximized();
await session.expectTerminalHasText(TERMINAL_MARKER);
});
test("closing maximized panel exits maximize and restores layout", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Close Max Test");
// Type a command in the terminal, then maximize
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
await session.expectTerminalHasText(TERMINAL_MARKER);
await session.clickMaximize();
await session.expectMaximized();
// Close the terminal tab via dockview's built-in tab close button.
const terminalTab = testPage.locator(".dv-tab:has(.dv-default-tab:has-text('Terminal'))");
const closeBtn = terminalTab.locator(".dv-default-tab-action");
await closeBtn.click();
// Should exit maximize and restore default layout minus the closed terminal
await expect(session.chat).toBeVisible({ timeout: 10_000 });
await expect(session.files).toBeVisible({ timeout: 10_000 });
await expect(session.sidebar).toBeVisible();
// Terminal should be gone (it was closed)
await expect(session.terminal).not.toBeVisible({ timeout: 5_000 });
});
});
+55
View File
@@ -352,6 +352,60 @@ export function setSessionLayout(sessionId: string, layout: object): void {
}
}
// --- Dockview per-session maximize state (sessionStorage) ---
const DOCKVIEW_SESSION_MAXIMIZE_PREFIX = "kandev.dockview.maximize.";
export type SessionMaximizeState = {
/** The pre-maximize (normal) layout to restore on exit-maximize. */
preMaximizeLayout: object;
/** Native dockview JSON (api.toJSON()) for the maximized layout. */
maximizedDockviewJson: object;
};
function isSessionMaximizeState(value: unknown): value is SessionMaximizeState {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.preMaximizeLayout === "object" &&
v.preMaximizeLayout !== null &&
typeof v.maximizedDockviewJson === "object" &&
v.maximizedDockviewJson !== null
);
}
export function getSessionMaximizeState(sessionId: string): SessionMaximizeState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isSessionMaximizeState(parsed) ? parsed : null;
} catch {
return null;
}
}
export function setSessionMaximizeState(sessionId: string, state: SessionMaximizeState): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`,
JSON.stringify(state),
);
} catch {
// Ignore write failures
}
}
export function removeSessionMaximizeState(sessionId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`);
} catch {
// Ignore
}
}
// Internal storage keys for open file tabs
const OPEN_FILES_KEY = "kandev.openFiles";
const ACTIVE_TAB_KEY = "kandev.activeTab";
@@ -537,6 +591,7 @@ export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void {
// Session-keyed storage — clean all sessions belonging to the task
for (const sessionId of sessionIds) {
removeSessionMaximizeState(sessionId);
removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`);
removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`);
removeSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`);
@@ -20,6 +20,7 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
sb.group.header.hidden = false;
sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX });
}
const oldChanges = api.getPanel("diff-files");
if (oldChanges) oldChanges.api.setTitle("Changes");
const oldFiles = api.getPanel("all-files");
+169 -34
View File
@@ -1,6 +1,12 @@
import { create } from "zustand";
import type { DockviewApi, AddPanelOptions, SerializedDockview } from "dockview-react";
import { getSessionLayout, setSessionLayout } from "@/lib/local-storage";
import {
getSessionLayout,
setSessionLayout,
getSessionMaximizeState,
setSessionMaximizeState,
removeSessionMaximizeState,
} from "@/lib/local-storage";
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
import {
SIDEBAR_GROUP,
@@ -121,6 +127,12 @@ type DockviewStore = {
activeFilePath: string | null;
pendingChatScrollTop: number | null;
setPendingChatScrollTop: (value: number | null) => void;
/** Saved layout from before a manual maximize. Null when not maximized. */
preMaximizeLayout: LayoutState | null;
/** The group ID that was maximized (used for session restore). */
maximizedGroupId: string | null;
maximizeGroup: (groupId: string) => void;
exitMaximizedLayout: () => void;
};
type StoreGet = () => DockviewStore;
@@ -152,6 +164,7 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio
* Only syncs widths for columns identified as "sidebar" or "right" to avoid
* capturing plan/preview/vscode column widths as stale "right" overrides. */
function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
if (api.hasMaximizedGroup()) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
@@ -179,6 +192,9 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
/** Capture the live sidebar/right pixel widths into pinnedWidths before a layout rebuild. */
function captureLiveWidths(api: DockviewApi, set: StoreSet): Map<string, number> {
if (api.hasMaximizedGroup()) {
api.exitMaximizedGroup();
}
syncPinnedWidthsFromApi(api, set);
return useDockviewStore.getState().pinnedWidths;
}
@@ -234,6 +250,8 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
if (!api) return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const safeWidth = api.width;
const safeHeight = api.height;
if (sidebarVisible) {
const current = fromDockviewApi(api);
const withoutSidebar: LayoutState = {
@@ -242,6 +260,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
set({ isRestoringLayout: true, sidebarVisible: false });
applyLayoutAndSet(api, withoutSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -254,6 +273,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
set({ isRestoringLayout: true, sidebarVisible: true });
applyLayoutAndSet(api, withSidebar, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -264,6 +284,8 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
if (!api) return;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const safeWidth = api.width;
const safeHeight = api.height;
if (rightPanelsVisible) {
const current = fromDockviewApi(api);
const withoutRight: LayoutState = {
@@ -275,6 +297,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
set({ isRestoringLayout: true, rightPanelsVisible: false });
applyLayoutAndSet(api, withoutRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -289,6 +312,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
set({ isRestoringLayout: true, rightPanelsVisible: true });
applyLayoutAndSet(api, withRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
@@ -381,6 +405,146 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
};
}
/** Restore a saved maximize state from sessionStorage onto the dockview API. */
function restoreMaximizeFromStorage(api: DockviewApi, sessionId: string, set: StoreSet): boolean {
const saved = getSessionMaximizeState(sessionId);
if (!saved) return false;
try {
api.fromJSON(saved.maximizedDockviewJson as SerializedDockview);
api.layout(api.width, api.height);
const ids = applyLayoutFixups(api);
const preMax = saved.preMaximizeLayout as unknown as LayoutState;
set({ ...ids, preMaximizeLayout: preMax });
} catch {
return false;
}
requestAnimationFrame(() => {
set({ isRestoringLayout: false });
});
return true;
}
function buildSessionSwitchAction(set: StoreSet, get: StoreGet) {
return (oldSessionId: string | null, newSessionId: string) => {
const { api, currentLayoutSessionId, preMaximizeLayout } = get();
if (!api || currentLayoutSessionId === newSessionId) return;
// First session adoption — onReady already built the layout; just adopt it.
if (!oldSessionId && !currentLayoutSessionId) {
set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId });
if (restoreMaximizeFromStorage(api, newSessionId, set)) return;
set({ isRestoringLayout: false, currentLayoutSessionId: newSessionId });
try {
setSessionLayout(newSessionId, api.toJSON());
} catch {
/* ignore */
}
return;
}
// When maximized, save native dockview JSON so we can restore via api.fromJSON().
if (oldSessionId && preMaximizeLayout) {
setSessionMaximizeState(oldSessionId, {
preMaximizeLayout: preMaximizeLayout as unknown as object,
maximizedDockviewJson: api.toJSON(),
});
} else if (oldSessionId) {
removeSessionMaximizeState(oldSessionId);
}
// Clear maximize state before switching — the new session has its own.
set({ preMaximizeLayout: null, maximizedGroupId: null });
if (oldSessionId)
try {
setSessionLayout(oldSessionId, api.toJSON());
} catch {
/* ignore */
}
set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId });
try {
if (restoreMaximizeFromStorage(api, newSessionId, set)) return;
const ids = performSessionSwitch({
api,
oldSessionId,
newSessionId,
safeWidth: api.width,
safeHeight: api.height,
buildDefault: (a) => get().buildDefaultLayout(a),
});
set(ids);
set({ isRestoringLayout: false });
} catch {
set({ isRestoringLayout: false });
}
};
}
function buildMaximizeActions(set: StoreSet, get: StoreGet) {
return {
maximizeGroup: (groupId: string) => {
const { api, preMaximizeLayout, currentLayoutSessionId } = get();
if (!api) return;
if (preMaximizeLayout) {
get().exitMaximizedLayout();
return;
}
const liveWidths = captureLiveWidths(api, set);
const current = fromDockviewApi(api);
let targetGroup: {
panels: LayoutState["columns"][0]["groups"][0]["panels"];
activePanel?: string;
} | null = null;
for (const col of current.columns) {
for (const g of col.groups) {
if (g.id === groupId) {
targetGroup = { panels: g.panels, activePanel: g.activePanel };
break;
}
}
if (targetGroup) break;
}
if (!targetGroup || targetGroup.panels.length === 0) return;
const sidebarCol = current.columns.find((c) => c.id === "sidebar");
const columns: LayoutState["columns"] = [];
if (sidebarCol) columns.push(sidebarCol);
columns.push({
id: "maximized",
groups: [{ panels: targetGroup.panels, activePanel: targetGroup.activePanel }],
});
const maximizedLayout: LayoutState = { columns };
set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId });
const safeWidth = api.width;
const safeHeight = api.height;
applyLayoutAndSet(api, maximizedLayout, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
// Persist to sessionStorage so maximize survives page refresh
if (currentLayoutSessionId) {
setSessionMaximizeState(currentLayoutSessionId, {
preMaximizeLayout: current as unknown as object,
maximizedDockviewJson: api.toJSON(),
});
}
set({ isRestoringLayout: false });
});
},
exitMaximizedLayout: () => {
const { api, preMaximizeLayout, currentLayoutSessionId } = get();
if (!api || !preMaximizeLayout) return;
const safeWidth = api.width;
const safeHeight = api.height;
const liveWidths = get().pinnedWidths;
set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null });
if (currentLayoutSessionId) {
removeSessionMaximizeState(currentLayoutSessionId);
}
applyLayoutAndSet(api, preMaximizeLayout, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
},
};
}
function performBuildDefault(
api: DockviewApi,
set: StoreSet,
@@ -463,39 +627,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
set((prev) => ({
deferredPanelActions: [...prev.deferredPanelActions, action],
})),
switchSessionLayout: (oldSessionId, newSessionId) => {
const { api, currentLayoutSessionId } = get();
if (!api || currentLayoutSessionId === newSessionId) return;
// When the first session becomes active (oldSessionId is null and no prior
// layout session), onReady already built the correct layout (possibly with
// an intent). Just adopt the current layout for the new session without
// rebuilding, which would lose the intent-based layout.
if (!oldSessionId && !currentLayoutSessionId) {
set({ currentLayoutSessionId: newSessionId });
try {
setSessionLayout(newSessionId, api.toJSON());
} catch {
/* ignore */
}
return;
}
const safeWidth = api.width;
const safeHeight = api.height;
set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId });
try {
const ids = performSessionSwitch({
api,
oldSessionId,
newSessionId,
safeWidth,
safeHeight,
buildDefault: (a) => get().buildDefaultLayout(a),
});
set(ids);
} finally {
set({ isRestoringLayout: false });
}
},
switchSessionLayout: buildSessionSwitchAction(set, get),
buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName),
resetLayout: () => {
const { api } = get();
@@ -503,6 +635,9 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
},
pendingChatScrollTop: null,
setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }),
preMaximizeLayout: null,
maximizedGroupId: null,
...buildMaximizeActions(set, get),
...buildPanelActions(set, get),
...buildExtraPanelActions(get),
}));
+1 -3
View File
@@ -118,9 +118,7 @@ export function computeColumnWidths(
): number[] {
const bucket = classifyColumns(columns, totalWidth, pinnedWidths);
const remainingSpace = Math.max(0, totalWidth - bucket.pinnedTotal);
const widths = computeFlexWidths(columns, bucket, remainingSpace);
return widths;
return computeFlexWidths(columns, bucket, remainingSpace);
}
/**