fix: sidebar task switcher shows outdated session state and custom maximize layout (#345)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user