From 5d6a2957152243b9713c45b6bec8f4ca447ba7e6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 19:09:07 +0200 Subject: [PATCH] feat(layout): refitEpoch test + implementation LayoutGrid --- .../layout/LayoutGrid.refitEpoch.test.tsx | 136 ++++++++++++++++++ frontend/src/features/layout/LayoutGrid.tsx | 21 ++- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx diff --git a/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx new file mode 100644 index 0000000..12820f4 --- /dev/null +++ b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx @@ -0,0 +1,136 @@ +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; + +const terminalViewSpy = vi.hoisted(() => vi.fn()); +const terminalMountSpy = vi.hoisted(() => vi.fn()); +const terminalUnmountSpy = vi.hoisted(() => vi.fn()); + +vi.mock("@/features/terminals", () => ({ + TerminalView: (props: { refitSignal?: number }) => { + terminalViewSpy(props); + useEffect(() => { + terminalMountSpy(); + return () => terminalUnmountSpy(); + }, []); + return ( +
+ ); + }, + ResumeConversationPopup: () => null, + useWritePortal: () => ({ + portal: { + onHumanData: () => {}, + isSuspended: () => false, + bindHandle: () => {}, + unbindHandle: () => {}, + }, + overlay: false, + }), +})); + +import { LayoutGrid } from "./LayoutGrid"; + +function renderGrid( + layout: MockLayoutGateway, + props: { projectId: string; cwd: string; layoutId?: string }, +) { + const gateways = { + layout, + terminal: new MockTerminalGateway(), + } as unknown as Gateways; + + return render( + + + , + ); +} + +function latestRefitSignal(): number { + const lastCall = terminalViewSpy.mock.calls.at(-1); + return Number(lastCall?.[0]?.refitSignal ?? -1); +} + +describe("LayoutGrid refit epoch (#4)", () => { + it("bumps TerminalView refitSignal after cwd/layout/project transitions", async () => { + const layout = new MockLayoutGateway(); + const { activeId: firstLayoutId } = await layout.listLayouts("p1"); + const { layoutId: secondLayoutId } = await layout.createLayout("p1", "Second"); + terminalViewSpy.mockClear(); + terminalMountSpy.mockClear(); + terminalUnmountSpy.mockClear(); + + const view = renderGrid(layout, { + projectId: "p1", + cwd: "/same/cwd", + layoutId: firstLayoutId, + }); + + await screen.findByTestId("mock-terminal-view"); + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(0)); + const initialSignal = latestRefitSignal(); + expect(terminalMountSpy).toHaveBeenCalledTimes(1); + + view.rerender( + + + , + ); + + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(initialSignal)); + const afterCwdSignal = latestRefitSignal(); + expect(terminalMountSpy).toHaveBeenCalledTimes(1); + expect(terminalUnmountSpy).not.toHaveBeenCalled(); + + view.rerender( + + + , + ); + + await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(afterCwdSignal)); + const afterLayoutSwitchSignal = latestRefitSignal(); + + view.rerender( + + + , + ); + + await waitFor(() => + expect(latestRefitSignal()).toBeGreaterThan(afterLayoutSwitchSignal), + ); + expect(terminalMountSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 7ae6c5c..86709e0 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -97,6 +97,11 @@ export function LayoutGrid({ }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); const work = useProjectWorkState(projectId); + const [refitEpoch, setRefitEpoch] = useState(0); + + useEffect(() => { + setRefitEpoch((epoch) => epoch + 1); + }, [projectId, layoutId, cwd, vm.layoutVersion]); if (!vm.layout) { return ( @@ -129,6 +134,7 @@ export function LayoutGrid({ projectId={projectId} workState={work.state} refreshWorkState={work.refresh} + refitSignal={refitEpoch} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -145,6 +151,7 @@ interface NodeViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -157,6 +164,7 @@ function NodeView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: NodeViewProps) { @@ -190,6 +198,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} /> ); @@ -202,6 +211,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -215,6 +225,7 @@ function NodeView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} /> @@ -234,6 +245,7 @@ interface LeafViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; } @@ -315,6 +327,7 @@ function LeafView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, }: LeafViewProps) { // A cell can be closed only when it lives inside a (binary) split: closing it @@ -950,7 +963,7 @@ function LeafView({ onSessionId={(sid) => void vm.setSession(id, sid)} agentMode={agentId != null} portal={agentId != null ? portal : undefined} - refitSignal={vm.layoutVersion} + refitSignal={refitSignal} /> {/* Write-portal overlay (ARCHITECTURE ยง20.3 step b/e): while a delegation is being injected into the agent's PTY, a grey veil with a centred @@ -1185,6 +1198,7 @@ interface SplitViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -1196,6 +1210,7 @@ function SplitView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: SplitViewProps) { @@ -1241,6 +1256,7 @@ function SplitView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} parentSplit={{ @@ -1336,6 +1352,7 @@ interface GridViewProps { projectId: string; workState: ProjectWorkState | null; refreshWorkState: () => Promise; + refitSignal: number; onOpenConversation?: (conversationId: string) => void; onOpenPluginsSettings?: () => void; } @@ -1347,6 +1364,7 @@ function GridView({ projectId, workState, refreshWorkState, + refitSignal, onOpenConversation, onOpenPluginsSettings, }: GridViewProps) { @@ -1388,6 +1406,7 @@ function GridView({ projectId={projectId} workState={workState} refreshWorkState={refreshWorkState} + refitSignal={refitSignal} onOpenConversation={onOpenConversation} onOpenPluginsSettings={onOpenPluginsSettings} />