feat(layout): refitEpoch test + implementation LayoutGrid
This commit is contained in:
136
frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx
Normal file
136
frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user