From 76c1dcce894bbfba566c7d9fb23cb6544cd7a023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 2 Mar 2026 14:12:11 +0000 Subject: [PATCH] fix: sidebar task switcher shows outdated session state and custom maximize layout (#345) --- .../task/dockview-desktop-layout.tsx | 183 ++++++++-------- .../task/dockview-header-actions.tsx | 36 ++-- .../components/task/passthrough-terminal.tsx | 13 ++ .../components/task/task-session-sidebar.tsx | 5 +- apps/web/components/task/use-review-dialog.ts | 69 ++++++ apps/web/e2e/fixtures/test-base.ts | 8 + apps/web/e2e/pages/session-page.ts | 86 +++++++- apps/web/e2e/tests/session-details-ux.spec.ts | 159 ++++++++++++++ apps/web/lib/local-storage.ts | 55 +++++ .../web/lib/state/dockview-layout-builders.ts | 1 + apps/web/lib/state/dockview-store.ts | 203 +++++++++++++++--- apps/web/lib/state/layout-manager/sizing.ts | 4 +- 12 files changed, 673 insertions(+), 149 deletions(-) create mode 100644 apps/web/components/task/use-review-dialog.ts create mode 100644 apps/web/e2e/tests/session-details-ux.spec.ts diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 23d30fd997..8ab54535e0 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -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 | null>(null); - const sessionIdRef = useRef(null); const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null; + const sessionIdRef = useRef(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); diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 4f363ab604..c403e94d67 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -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 ( <> - diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index dd6c9cee95..ce3ea5a774 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -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; diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index e023aaecb8..e57cdead6e 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -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, diff --git a/apps/web/components/task/use-review-dialog.ts b/apps/web/components/task/use-review-dialog.ts new file mode 100644 index 0000000000..5b30cde3fe --- /dev/null +++ b/apps/web/components/task/use-review-dialog.ts @@ -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, + }; +} diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 4ceae43cda..fd72d13af5 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -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" }, diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 562bb0042c..2bae682218 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } } diff --git a/apps/web/e2e/tests/session-details-ux.spec.ts b/apps/web/e2e/tests/session-details-ux.spec.ts new file mode 100644 index 0000000000..3f44965a3e --- /dev/null +++ b/apps/web/e2e/tests/session-details-ux.spec.ts @@ -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 { + 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 }); + }); +}); diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index cac20e5f63..5f343fe735 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -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; + 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}`); diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index a525562f32..09e042780e 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -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"); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 31ce9814e7..5cb5e4adaa 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -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 { + 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((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((set, get) => ({ }, pendingChatScrollTop: null, setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }), + preMaximizeLayout: null, + maximizedGroupId: null, + ...buildMaximizeActions(set, get), ...buildPanelActions(set, get), ...buildExtraPanelActions(get), })); diff --git a/apps/web/lib/state/layout-manager/sizing.ts b/apps/web/lib/state/layout-manager/sizing.ts index ba5fcf4147..952704abdc 100644 --- a/apps/web/lib/state/layout-manager/sizing.ts +++ b/apps/web/lib/state/layout-manager/sizing.ts @@ -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); } /**