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
4 changes: 3 additions & 1 deletion src/browser/features/Analytics/AnalyticsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<Menu className="h-4 w-4" />
</Button>
Expand Down
45 changes: 44 additions & 1 deletion src/browser/stories/App.phoneViewports.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -138,6 +139,48 @@ export const IPhone16e: AppStory = {
},
};

export const IPhone16eAnalyticsSidebarControl: AppStory = {
globals: {
viewport: { value: "mobile1", isRotated: false },
},
render: () => (
<AppWithMocks
setup={() => {
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: () => (
<AppWithMocks
Expand Down
37 changes: 0 additions & 37 deletions tests/ui/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,6 @@ import type { RenderedApp } from "./renderReviewPanel";
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
import { useGitStatusStoreRaw } from "@/browser/stores/GitStatusStore";

// ═══════════════════════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════════════════════

export type EventCollector = { getEvents(): unknown[] };

type ToolCallEndEvent = { type: "tool-call-end"; toolName: string };

// ═══════════════════════════════════════════════════════════════════════════════
// EVENT HELPERS
// ═══════════════════════════════════════════════════════════════════════════════

function isToolCallEndEvent(event: unknown): event is ToolCallEndEvent {
if (typeof event !== "object" || event === null) return false;
const record = event as { type?: unknown; toolName?: unknown };
return record.type === "tool-call-end" && typeof record.toolName === "string";
}

/**
* Wait for a tool-call-end event with the specified tool name.
*/
export async function waitForToolCallEnd(
collector: EventCollector,
toolName: string,
timeoutMs: number = 10_000
): Promise<void> {
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
// ═══════════════════════════════════════════════════════════════════════════════
Expand Down
32 changes: 32 additions & 0 deletions tests/ui/layout/analyticsHeader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 8 additions & 28 deletions tests/ui/review/refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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();
});
Expand All @@ -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({
Expand All @@ -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({
Expand Down
Loading