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}
/>