feat(layout): refitEpoch test + implementation LayoutGrid

This commit is contained in:
2026-07-28 19:09:07 +02:00
parent 50219df4e5
commit 5d6a295715
2 changed files with 156 additions and 1 deletions

View File

@ -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 (
<div
data-testid="mock-terminal-view"
data-refit-signal={String(props.refitSignal)}
/>
);
},
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(
<DIProvider gateways={gateways}>
<LayoutGrid {...props} />
</DIProvider>,
);
}
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(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid
projectId="p1"
cwd="/same/cwd/after-transition"
layoutId={firstLayoutId}
/>
</DIProvider>,
);
await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(initialSignal));
const afterCwdSignal = latestRefitSignal();
expect(terminalMountSpy).toHaveBeenCalledTimes(1);
expect(terminalUnmountSpy).not.toHaveBeenCalled();
view.rerender(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid
projectId="p1"
cwd="/same/cwd/after-transition"
layoutId={secondLayoutId}
/>
</DIProvider>,
);
await waitFor(() => expect(latestRefitSignal()).toBeGreaterThan(afterCwdSignal));
const afterLayoutSwitchSignal = latestRefitSignal();
view.rerender(
<DIProvider
gateways={{
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways}
>
<LayoutGrid projectId="p2" cwd="/same/cwd" />
</DIProvider>,
);
await waitFor(() =>
expect(latestRefitSignal()).toBeGreaterThan(afterLayoutSwitchSignal),
);
expect(terminalMountSpy).toHaveBeenCalled();
});
});

View File

@ -97,6 +97,11 @@ export function LayoutGrid({
}: LayoutGridProps) { }: LayoutGridProps) {
const vm = useLayout(projectId, layoutId); const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId); const work = useProjectWorkState(projectId);
const [refitEpoch, setRefitEpoch] = useState(0);
useEffect(() => {
setRefitEpoch((epoch) => epoch + 1);
}, [projectId, layoutId, cwd, vm.layoutVersion]);
if (!vm.layout) { if (!vm.layout) {
return ( return (
@ -129,6 +134,7 @@ export function LayoutGrid({
projectId={projectId} projectId={projectId}
workState={work.state} workState={work.state}
refreshWorkState={work.refresh} refreshWorkState={work.refresh}
refitSignal={refitEpoch}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -145,6 +151,7 @@ interface NodeViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -157,6 +164,7 @@ function NodeView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: NodeViewProps) { }: NodeViewProps) {
@ -190,6 +198,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
/> />
); );
@ -202,6 +211,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -215,6 +225,7 @@ function NodeView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />
@ -234,6 +245,7 @@ interface LeafViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
} }
@ -315,6 +327,7 @@ function LeafView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
}: LeafViewProps) { }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it // 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)} onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null} agentMode={agentId != null}
portal={agentId != null ? portal : undefined} portal={agentId != null ? portal : undefined}
refitSignal={vm.layoutVersion} refitSignal={refitSignal}
/> />
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation {/* 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 is being injected into the agent's PTY, a grey veil with a centred
@ -1185,6 +1198,7 @@ interface SplitViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -1196,6 +1210,7 @@ function SplitView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: SplitViewProps) { }: SplitViewProps) {
@ -1241,6 +1256,7 @@ function SplitView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{ parentSplit={{
@ -1336,6 +1352,7 @@ interface GridViewProps {
projectId: string; projectId: string;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void; onOpenPluginsSettings?: () => void;
} }
@ -1347,6 +1364,7 @@ function GridView({
projectId, projectId,
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal,
onOpenConversation, onOpenConversation,
onOpenPluginsSettings, onOpenPluginsSettings,
}: GridViewProps) { }: GridViewProps) {
@ -1388,6 +1406,7 @@ function GridView({
projectId={projectId} projectId={projectId}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings} onOpenPluginsSettings={onOpenPluginsSettings}
/> />