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
38 changes: 33 additions & 5 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime";
import { createModelCapabilities, createModelSelection } from "@threadlines/shared/model";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { StrictMode } from "react";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { HttpResponse, http, ws } from "msw";
Expand Down Expand Up @@ -2376,6 +2377,8 @@ async function mountChatView(options: {
resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
initialPath?: string;
waitForBootstrap?: boolean;
/** Mount under StrictMode, as main.tsx does, so mount-time effect cleanups run. */
strictMode?: boolean;
}): Promise<MountedChatView> {
fixture = buildFixture(options.snapshot);
options.configureFixture?.(fixture);
Expand All @@ -2401,14 +2404,14 @@ async function mountChatView(options: {
}),
);

const screen = await render(
const app = (
<AppAtomRegistryProvider>
<RouterProvider router={router} />
</AppAtomRegistryProvider>,
{
container: host,
},
</AppAtomRegistryProvider>
);
const screen = await render(options.strictMode ? <StrictMode>{app}</StrictMode> : app, {
container: host,
});

await waitForWsClient();
if (options.waitForBootstrap !== false) {
Expand Down Expand Up @@ -7835,6 +7838,31 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("stays open under StrictMode", async () => {
// The app mounts under StrictMode, which runs every new effect's cleanup
// once right after mount. An unmount reset on the open dialog turned that
// into a palette that closed the instant it opened, in dev only.
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-command-palette-strict-mode" as MessageId,
targetText: "command palette strict mode",
}),
strictMode: true,
});

try {
await openCommandPaletteFromTrigger();
await waitForLayout();
await waitForLayout();

expect(useCommandPaletteStore.getState().open).toBe(true);
await expect.element(page.getByTestId("command-palette")).toBeInTheDocument();
} finally {
await mounted.cleanup();
}
});

it("filters command palette results as the user types", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
28 changes: 18 additions & 10 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,19 @@ export function CommandPalette({ children }: { children: ReactNode }) {
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, terminalOpen, toggleOpen]);

// The store outlives this tree, so a palette left open when the shell goes
// away (auth gate, pairing route, a test harness unmounting) must not greet
// the next mount already open. This wrapper mounts once with the shell, so
// its cleanup is the real teardown -- unlike the open dialog's, which
// StrictMode also runs right after mount.
useEffect(() => {
return () => {
if (useCommandPaletteStore.getState().open) {
setOpen(false);
}
};
}, [setOpen]);

return (
<ComposerHandleContext value={composerHandleRef}>
<CommandDialog open={open} onOpenChange={setOpen}>
Expand Down Expand Up @@ -479,17 +492,12 @@ function OpenCommandPaletteDialog() {
const composerHandleRef = useComposerHandleContext();
// This component mounts once per palette session, so its mount-time
// generation identifies the session every deferred close below belongs to.
// Async flows and the unmount reset close via `closeIfGeneration`: if the
// user closed or reopened the palette while a request was in flight (or
// React deferred the unmount cleanup past a new session), the stale close
// is a no-op instead of slamming a palette it does not own.
// Async flows close via `closeIfGeneration`: if the user closed or reopened
// the palette while a request was in flight, the stale close is a no-op
// instead of slamming a palette it does not own. There is deliberately no
// unmount cleanup here: React StrictMode runs a new effect's cleanup once
// right after mount, which closed the palette the instant it opened in dev.
const [sessionGeneration] = useState(() => useCommandPaletteStore.getState().openGeneration);

useEffect(() => {
return () => {
closeIfGeneration(sessionGeneration);
};
}, [closeIfGeneration, sessionGeneration]);
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isActionsOnly = deferredQuery.startsWith(">");
Expand Down
Loading