From 5b41ba50b0e2bc8df7529cad4d18bb5dc225790a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 00:25:52 -0700 Subject: [PATCH 1/2] Add tests for canvas dismiss on rail leave (CL-6819) --- apps/web/test/shell-chrome-wiring.test.tsx | 192 +++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/apps/web/test/shell-chrome-wiring.test.tsx b/apps/web/test/shell-chrome-wiring.test.tsx index 32fd50f10..fd1ed1249 100644 --- a/apps/web/test/shell-chrome-wiring.test.tsx +++ b/apps/web/test/shell-chrome-wiring.test.tsx @@ -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"; @@ -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 ( +
+ + + + + {profile === null ? "null" : "set"} + + + {artifact === null ? "null" : "set"} + + + {routine === null ? "null" : "set"} + +
+ ); +} + +function RouteHarness({ path }: { readonly path: string }) { + return ( + + + + + + + + + + + + ); +} + +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( + `[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 { + await act(async () => { + root.render(); + }); + } + + 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"); + }); +}); From fc0f2826bc3924a76f400c8cbade655344a0a766 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 00:50:45 -0700 Subject: [PATCH 2/2] Dismiss canvas when in-app nav leaves a rail surface (CL-6819) --- apps/web/src/shell/shell-chrome-provider.tsx | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/web/src/shell/shell-chrome-provider.tsx b/apps/web/src/shell/shell-chrome-provider.tsx index ac0a6a143..bb3fc800d 100644 --- a/apps/web/src/shell/shell-chrome-provider.tsx +++ b/apps/web/src/shell/shell-chrome-provider.tsx @@ -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, @@ -68,6 +78,7 @@ export function ShellChromeProvider({ // drop canvas state without treating the initial null→ready resolve as a // switch. const previousTenantIdRef = useRef(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 @@ -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)); }, []);