diff --git a/src/browser/features/Analytics/AnalyticsDashboard.tsx b/src/browser/features/Analytics/AnalyticsDashboard.tsx
index 8687218fd1..9af4a3bbc8 100644
--- a/src/browser/features/Analytics/AnalyticsDashboard.tsx
+++ b/src/browser/features/Analytics/AnalyticsDashboard.tsx
@@ -209,7 +209,9 @@ export function AnalyticsDashboard(props: AnalyticsDashboardProps) {
onClick={props.onToggleLeftSidebarCollapsed}
title="Open sidebar"
aria-label="Open sidebar"
- className="text-muted hover:text-foreground hidden h-6 w-6 md:inline-flex"
+ // The left sidebar is fully off-canvas on narrow viewports, so this route must
+ // expose the shared mobile menu control instead of hiding its only opener.
+ className="mobile-menu-btn text-muted hover:text-foreground hidden h-6 w-6 md:inline-flex"
>
diff --git a/src/browser/stories/App.phoneViewports.stories.tsx b/src/browser/stories/App.phoneViewports.stories.tsx
index 3f8d66643d..5192f1a192 100644
--- a/src/browser/stories/App.phoneViewports.stories.tsx
+++ b/src/browser/stories/App.phoneViewports.stories.tsx
@@ -5,11 +5,12 @@
* Pixel snapshots both light and dark themes at the phone viewport.
*/
-import { within, waitFor } from "@storybook/test";
+import { userEvent, within, waitFor } from "@storybook/test";
import type { ComponentType } from "react";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { LEFT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage";
import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
@@ -138,6 +139,48 @@ export const IPhone16e: AppStory = {
},
};
+export const IPhone16eAnalyticsSidebarControl: AppStory = {
+ globals: {
+ viewport: { value: "mobile1", isRotated: false },
+ },
+ render: () => (
+ {
+ const client = setupSimpleChatStory({
+ workspaceId: "ws-iphone-analytics",
+ workspaceName: "analytics-mobile",
+ projectName: "mux",
+ messages: [...MESSAGES],
+ });
+ updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false);
+ return client;
+ }}
+ />
+ ),
+ decorators: [IPhone16eDecorator],
+ parameters: {
+ ...appMeta.parameters,
+ pixel: {
+ matrix: { themes: ["dark", "light"], viewports: ["phone"] },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await stabilizePhoneViewportStory(canvasElement);
+
+ await userEvent.click(await canvas.findByTestId("analytics-button"));
+ await canvas.findByTestId("analytics-header");
+ await userEvent.click(canvas.getByRole("button", { name: "Collapse sidebar" }));
+
+ const openSidebarButton = await canvas.findByRole("button", { name: "Open sidebar" });
+ if (!openSidebarButton.classList.contains("mobile-menu-btn")) {
+ throw new Error("Analytics sidebar opener is not enabled for the phone viewport");
+ }
+
+ blurActiveElement();
+ },
+};
+
export const IPhone17ProMax: AppStory = {
render: () => (
{
- const start = Date.now();
- while (Date.now() - start < timeoutMs) {
- const match = collector
- .getEvents()
- .find((event) => isToolCallEndEvent(event) && event.toolName === toolName);
- if (match) return;
- await new Promise((r) => setTimeout(r, 50));
- }
- throw new Error(`Timed out waiting for tool-call-end: ${toolName}`);
-}
-
// ═══════════════════════════════════════════════════════════════════════════════
// REFRESH BUTTON HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
diff --git a/tests/ui/layout/analyticsHeader.test.ts b/tests/ui/layout/analyticsHeader.test.ts
index 68f929cb0a..9981c317ff 100644
--- a/tests/ui/layout/analyticsHeader.test.ts
+++ b/tests/ui/layout/analyticsHeader.test.ts
@@ -63,6 +63,38 @@ describe("Analytics header titlebar contract", () => {
}
}, 60_000);
+ test("narrow browser mode can reopen the off-canvas left sidebar", async () => {
+ const app = await createAppHarness({ branchPrefix: "analytics-sidebar-mobile" });
+
+ try {
+ const header = await openAnalyticsAndGetHeader(app.view.container);
+ const collapseSidebarButton = app.view.container.querySelector(
+ 'button[aria-label="Collapse sidebar"]'
+ ) as HTMLButtonElement | null;
+ expect(collapseSidebarButton).not.toBeNull();
+ fireEvent.click(collapseSidebarButton!);
+
+ const openSidebarButton = await waitFor(() => {
+ const button = header.querySelector(
+ 'button[aria-label="Open sidebar"]'
+ ) as HTMLButtonElement | null;
+ if (!button) {
+ throw new Error("Analytics sidebar opener not found");
+ }
+ return button;
+ });
+ expect(openSidebarButton.classList.contains("mobile-menu-btn")).toBe(true);
+
+ fireEvent.click(openSidebarButton);
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.leftSidebarCollapsed).toBe("false");
+ });
+ } finally {
+ await app.dispose();
+ }
+ }, 60_000);
+
test("desktop linux mode: header has h-9, titlebar-drag, and titlebar-safe-right", async () => {
const app = await createAppHarness({
branchPrefix: "analytics-hdr-linux",
diff --git a/tests/ui/review/refresh.test.ts b/tests/ui/review/refresh.test.ts
index 52fe18ac37..ba4383f6e2 100644
--- a/tests/ui/review/refresh.test.ts
+++ b/tests/ui/review/refresh.test.ts
@@ -1,7 +1,7 @@
import "../dom";
import { fireEvent, waitFor } from "@testing-library/react";
-import { shouldRunIntegrationTests, validateApiKeys } from "../../testUtils";
+import { shouldRunIntegrationTests } from "../../testUtils";
import { STORAGE_KEYS } from "@/constants/workspaceDefaults";
import { getReviewsKey } from "@/common/constants/storage";
import {
@@ -10,15 +10,11 @@ import {
createSharedRepo,
withSharedWorkspace,
} from "../../ipc/sendMessageTestHelpers";
-import { HAIKU_MODEL, sendMessageWithModel } from "../../ipc/helpers";
-import type { ToolPolicy } from "../../../src/common/utils/tools/toolPolicy";
-
import { installDom } from "../dom";
import { renderReviewPanel, type RenderedApp } from "../renderReviewPanel";
import {
cleanupView,
setupWorkspaceView,
- waitForToolCallEnd,
waitForRefreshButtonIdle,
assertRefreshButtonHasLastRefreshInfo,
simulateFileModifyingToolEnd,
@@ -31,8 +27,6 @@ configureTestRetries(2);
const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip;
-validateApiKeys(["ANTHROPIC_API_KEY"]);
-
/**
* Helper to set up the full App UI and navigate to the Review tab.
* Returns the refresh button for assertions.
@@ -425,10 +419,10 @@ describeIntegration("ReviewPanel simulated tool refresh (UI + ORPC, no LLM)", ()
});
// ═══════════════════════════════════════════════════════════════════════════════
-// AUTO REFRESH TEST (slow, requires LLM)
+// AUTO REFRESH TEST
// ═══════════════════════════════════════════════════════════════════════════════
-describeIntegration("ReviewPanel auto refresh (UI + ORPC + live LLM)", () => {
+describeIntegration("ReviewPanel auto refresh (UI + ORPC)", () => {
beforeAll(async () => {
await createSharedRepo();
});
@@ -438,7 +432,7 @@ describeIntegration("ReviewPanel auto refresh (UI + ORPC + live LLM)", () => {
});
test("tool-call-end triggers scheduled refresh", async () => {
- await withSharedWorkspace("anthropic", async ({ env, workspaceId, collector, metadata }) => {
+ await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => {
const cleanupDom = installDom();
const view = renderReviewPanelForRefreshTests({
@@ -465,24 +459,10 @@ describeIntegration("ReviewPanel auto refresh (UI + ORPC + live LLM)", () => {
// Without a scheduled refresh, the UI should not pick this up yet.
expect(view.queryByText(new RegExp(AUTO_MARKER))).toBeNull();
- // Trigger a tool-call-end event via bash.
- const FORCE_BASH: ToolPolicy = [{ regex_match: "bash", action: "require" }];
-
- const autoRes = await sendMessageWithModel(
- env,
- workspaceId,
- 'Use bash to run: echo ping. Set display_name="ping" and timeout_secs=30. Do not modify files.',
- HAIKU_MODEL,
- {
- agentId: "exec",
- thinkingLevel: "off",
- toolPolicy: FORCE_BASH,
- }
- );
- expect(autoRes.success).toBe(true);
-
- await collector.waitForEvent("stream-end", 30_000);
- await waitForToolCallEnd(collector, "bash");
+ // Exercise the same WorkspaceStore event emitted after file-modifying tools complete.
+ // A live model made this behavioral test depend on whether the provider chose/called bash,
+ // which repeatedly flaked in CI before the event under test was ever emitted.
+ simulateFileModifyingToolEnd(workspaceId);
// Verify the workspace actually changed
const statusRes = await env.orpc.workspace.executeBash({