Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/web/src/shell/shell-chrome-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ import {
type RoutinePanelSubject,
} from "./canvas-availability";

/** First pathname segment, ignoring query and hash. Nested detail under the
* same surface (`/routines` vs `/routines/:id`) shares a prefix; a rail leave
* (`/routines` → `/insights`) does not. */
function inAppRoutePrefix(path: string): string {
const pathname = path.split("?")[0]?.split("#")[0] ?? "";
const trimmed = pathname.startsWith("/") ? pathname.slice(1) : pathname;
const slash = trimmed.indexOf("/");
return slash === -1 ? trimmed : trimmed.slice(0, slash);
}

export function ShellChromeProvider({
path,
navigate,
Expand All @@ -68,6 +78,7 @@ export function ShellChromeProvider({
// drop canvas state without treating the initial null→ready resolve as a
// switch.
const previousTenantIdRef = useRef<string | null>(selectedTenantId);
const previousRoutePrefixRef = useRef(inAppRoutePrefix(path));

// A switch clears auxiliary canvas content and leaves any conversation
// deep link so the stage does not keep a foreign conversation under the
Expand All @@ -89,6 +100,17 @@ export function ShellChromeProvider({
previousTenantIdRef.current = selectedTenantId;
}, [path, selectedTenantId, navigate]);

// Leaving a rail surface dismisses auxiliary canvas content so a compact
// viewport that hid the column cannot resurrect it when the shell expands
// again. Nested detail and query-only changes share a prefix and keep the
// pane.
useEffect(() => {
const nextPrefix = inAppRoutePrefix(path);
if (previousRoutePrefixRef.current === nextPrefix) return;
previousRoutePrefixRef.current = nextPrefix;
setCanvasState((state) => closeCanvasContent(state));
}, [path]);

const openProfile = useCallback((subject: ProfileSubject) => {
setCanvasState((state) => openProfileInCanvas(state, subject));
}, []);
Expand Down
192 changes: 192 additions & 0 deletions apps/web/test/shell-chrome-wiring.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { BenchProvider } from "../src/bench-context";
import { runActionCommand } from "../src/command-palette-actions";
import { NavigationProvider } from "../src/navigation";
import {
useCanvasColumnArtifact,
useCanvasColumnProfile,
useCanvasColumnRoutine,
useCloseCanvas,
useOpenArtifactInCanvas,
useOpenProfileInCanvas,
useOpenRoutineInCanvas,
} from "../src/shell/canvas-availability";
Expand Down Expand Up @@ -174,3 +178,191 @@ describe("palette actions reach the real shell state (CL-5936 sibling-context re
).toBeNull();
});
});

const sampleArtifact = {
id: "art_1",
title: "Notes",
rendererKind: "doc" as const,
content: "hello",
};

/** Reads canvas slots from ShellChromeProvider without mounting AppShell —
* opening a routine would otherwise pull in RoutinePanel's fetches. Compact
* layout hides the column but leaves slot state; these probes are the
* resurrection check. */
function CanvasSlotsProbe() {
const openProfile = useOpenProfileInCanvas();
const openArtifact = useOpenArtifactInCanvas();
const openRoutine = useOpenRoutineInCanvas();
const profile = useCanvasColumnProfile();
const artifact = useCanvasColumnArtifact();
const routine = useCanvasColumnRoutine();

return (
<div>
<button
type="button"
data-testid="probe-open-profile"
onClick={() => openProfile(sampleProfile)}
>
Open profile
</button>
<button
type="button"
data-testid="probe-open-artifact"
onClick={() => openArtifact(sampleArtifact)}
>
Open artifact
</button>
<button
type="button"
data-testid="probe-open-routine"
onClick={() => openRoutine({ routineId: null })}
>
Open routine
</button>
<span data-testid="canvas-profile">
{profile === null ? "null" : "set"}
</span>
<span data-testid="canvas-artifact">
{artifact === null ? "null" : "set"}
</span>
<span data-testid="canvas-routine">
{routine === null ? "null" : "set"}
</span>
</div>
);
}

function RouteHarness({ path }: { readonly path: string }) {
return (
<TestQueryProvider>
<NavigationProvider navigate={noop}>
<BenchProvider>
<ProviderHealthProvider>
<ShellChromeProvider path={path} navigate={noop}>
<CanvasSlotsProbe />
</ShellChromeProvider>
</ProviderHealthProvider>
</BenchProvider>
</NavigationProvider>
</TestQueryProvider>
);
}

describe("canvas dismisses when in-app nav leaves a rail surface (CL-6819)", () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
stubMatchMedia({});
globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(emptyMemberships())) as typeof fetch;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

function click(testId: string): void {
const button = container.querySelector<HTMLButtonElement>(
`[data-testid="${testId}"]`,
);
if (button === null) throw new Error(`${testId} not rendered`);
button.click();
}

function slot(name: "routine" | "profile" | "artifact"): string {
const el = container.querySelector(`[data-testid="canvas-${name}"]`);
if (el === null) throw new Error(`canvas-${name} not rendered`);
return el.textContent?.trim() ?? "";
}

async function renderAt(path: string): Promise<void> {
await act(async () => {
root.render(<RouteHarness path={path} />);
});
}

test("leaving Routines for Insights dismisses the create pane", async () => {
await renderAt("/routines");
await act(async () => {
click("probe-open-routine");
});
expect(slot("routine")).toBe("set");

await renderAt("/insights");
expect(slot("routine")).toBe("null");
});

test("leaving Routines for Files dismisses the create pane", async () => {
await renderAt("/routines");
await act(async () => {
click("probe-open-routine");
});
expect(slot("routine")).toBe("set");

await renderAt("/files");
expect(slot("routine")).toBe("null");
});

test("opening a routine detail under Routines does not dismiss the pane", async () => {
await renderAt("/routines");
await act(async () => {
click("probe-open-routine");
});
expect(slot("routine")).toBe("set");

await renderAt("/routines/rt_1");
expect(slot("routine")).toBe("set");
});

test("query-only changes do not dismiss the pane", async () => {
await renderAt("/routines?tab=list");
await act(async () => {
click("probe-open-routine");
});
expect(slot("routine")).toBe("set");

await renderAt("/routines?tab=runs");
expect(slot("routine")).toBe("set");
});

test("leaving a rail surface dismisses a profile pane", async () => {
await renderAt("/routines");
await act(async () => {
click("probe-open-profile");
});
expect(slot("profile")).toBe("set");

await renderAt("/insights");
expect(slot("profile")).toBe("null");
});

test("leaving a rail surface dismisses an artifact pane", async () => {
await renderAt("/files");
await act(async () => {
click("probe-open-artifact");
});
expect(slot("artifact")).toBe("set");

await renderAt("/insights");
expect(slot("artifact")).toBe("null");
});

test("route dismiss clears canvas state even when compact layout hides the column", async () => {
stubMatchMedia({ "(max-width: 1099px)": true });
await renderAt("/routines");
await act(async () => {
click("probe-open-routine");
});
expect(slot("routine")).toBe("set");

await renderAt("/insights");
expect(slot("routine")).toBe("null");
});
});
Loading