Compare commits

...

1 Commits

Author SHA1 Message Date
Developer 415aecc0dd fix: constrain dialog content to the viewport
- Add dynamic viewport bounds and scrollable body regions to dialogs and modals\n- Make tool-launch popups use the shared scrollable body pattern\n- Keep mobile sheets, action sheets, and notification popups scroll-contained\n- Restore the ProjectsPage test setup required for the frontend suite\n\nOpenSpec: fix-dialog-scroll-containment\nQuality gates: npm run typecheck, npm run lint, npm test (88 passed), npm run build
2026-07-15 10:20:20 +00:00
9 changed files with 160 additions and 62 deletions
@@ -578,19 +578,23 @@ export const InstanceList = ({
{showCreate && ( {showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h2>Launch Tool</h2> <div className="dialog-header">
<CreateSessionForm <h2>Launch Tool</h2>
projects={[]} </div>
repositories={[]} <div className="dialog-body">
toolTypes={toolTypes} <CreateSessionForm
fixedProjectId={projectId} projects={[]}
fixedRepoId={repoId} repositories={[]}
projectName={projectName} toolTypes={toolTypes}
repoName={repoName} fixedProjectId={projectId}
onSuccess={handleCreateSuccess} fixedRepoId={repoId}
onCancel={() => setShowCreate(false)} projectName={projectName}
submitLabel="Launch" repoName={repoName}
/> onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
</div>
</div> </div>
</div> </div>
)} )}
@@ -62,6 +62,7 @@ export function StartToolFAB() {
</button> </button>
</div> </div>
<div className="modal-body">
{workspacesLoading ? ( {workspacesLoading ? (
<p className="muted">Loading workspaces...</p> <p className="muted">Loading workspaces...</p>
) : workspaces.length === 0 ? ( ) : workspaces.length === 0 ? (
@@ -110,6 +111,7 @@ export function StartToolFAB() {
/> />
</> </>
)} )}
</div>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,47 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkspaceToolsPanel } from "./workspace-tools-panel";
import type { Workspace } from "../../../types/workspace";
vi.mock("../../../hooks/use-workspace-instances", () => ({
useWorkspaceInstances: () => ({
instances: [],
loading: false,
refresh: vi.fn(),
}),
}));
vi.mock("../tool/tool-starter", () => ({
ToolStarter: () => <div data-testid="tool-starter" />,
}));
const workspace: Workspace = {
id: "workspace-1",
name: "Main",
repo_id: "repo-1",
repo_name: "repository",
repo_ssh_key_id: null,
project_id: "project-1",
project_name: "Project",
user_id: "user-1",
branch: "main",
path: "/workspace",
status: "ready",
last_sync_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
instance_count: 0,
};
describe("WorkspaceToolsPanel", () => {
it("places the tool launcher inside the shared scrollable dialog body", () => {
const { container } = render(<WorkspaceToolsPanel workspace={workspace} />);
fireEvent.click(screen.getByRole("button", { name: "Start Tool" }));
const dialogBody = container.querySelector(".dialog-body");
expect(dialogBody).toContainElement(screen.getByTestId("tool-starter"));
});
});
@@ -64,15 +64,19 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
{showModal && ( {showModal && (
<div className="dialog-overlay" onClick={() => setShowModal(false)}> <div className="dialog-overlay" onClick={() => setShowModal(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}> <div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3> <div className="dialog-header">
<ToolStarter <h3>Start Tool</h3>
workspace={workspace} </div>
onStarted={() => { <div className="dialog-body">
setShowModal(false); <ToolStarter
void refresh(); workspace={workspace}
}} onStarted={() => {
onCancel={() => setShowModal(false)} setShowModal(false);
/> void refresh();
}}
onCancel={() => setShowModal(false)}
/>
</div>
</div> </div>
</div> </div>
)} )}
+22 -27
View File
@@ -1,17 +1,26 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./ProjectsPage"; import { ProjectsPage } from "./ProjectsPage";
import * as projectsApi from "../api/projects"; import * as projectsApi from "../api/projects";
import { SessionsProvider } from "../state/sessions";
import type { ProjectWithRepos } from "../types";
const mockProjects = [ vi.mock("../api/sessions", () => ({
getUserSessions: vi.fn().mockResolvedValue([]),
}));
const mockProjects: ProjectWithRepos[] = [
{ {
id: "proj-1", id: "proj-1",
name: "Alpha Project", name: "Alpha Project",
description: "First project", description: "First project",
owner_id: "user-1", owner_id: "user-1",
default_ssh_key_id: null, default_ssh_key_id: null,
repositories: [],
created_at: "2026-07-01T00:00:00Z",
}, },
{ {
id: "proj-2", id: "proj-2",
@@ -19,6 +28,8 @@ const mockProjects = [
description: null, description: null,
owner_id: "user-1", owner_id: "user-1",
default_ssh_key_id: null, default_ssh_key_id: null,
repositories: [],
created_at: "2026-07-01T00:00:00Z",
}, },
]; ];
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
it("renders loading state initially", () => { it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {})); vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
expect(screen.getByText(/loading projects/i)).toBeInTheDocument(); expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
}); });
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
it("renders project list after loading", async () => { it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => { it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => { it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail")); vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]); const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]); const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument(); expect(screen.getByText("Alpha Project")).toBeInTheDocument();
}); });
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null; const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found"); if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i })); fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined); const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument(); expect(screen.getByText("Alpha Project")).toBeInTheDocument();
}); });
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null; const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found"); if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i })); fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
+13 -13
View File
@@ -453,12 +453,16 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.dialog { .dialog,
.modal-content,
.commit-dialog {
width: 100%; width: 100%;
max-width: 32rem; max-width: 32rem;
max-height: calc(100vh - var(--space-8)); max-height: calc(100vh - var(--space-8));
max-height: calc(100dvh - var(--space-8));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -468,16 +472,6 @@
.modal-content { .modal-content {
/* deprecated alias */ /* deprecated alias */
width: 100%;
max-width: 32rem;
max-height: calc(100vh - var(--space-8));
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
} }
.dialog-lg { .dialog-lg {
@@ -500,9 +494,13 @@
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
} }
.dialog-body { .dialog-body,
.modal-body {
flex: 1; flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: var(--space-4); padding: var(--space-4);
} }
@@ -543,9 +541,11 @@
} }
.dialog, .dialog,
.modal-content { .modal-content,
.commit-dialog {
max-width: 100%; max-width: 100%;
max-height: calc(100vh - var(--space-6)); max-height: calc(100vh - var(--space-6));
max-height: calc(100dvh - var(--space-6));
border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
} }
} }
+10
View File
@@ -2328,8 +2328,10 @@ a.nav-item,
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
max-height: 70vh; max-height: 70vh;
max-height: 70dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
animation: slide-up 0.2s ease-out; animation: slide-up 0.2s ease-out;
} }
@@ -2365,8 +2367,12 @@ a.nav-item,
} }
.mobile-bottom-sheet-content { .mobile-bottom-sheet-content {
flex: 1;
min-height: 0;
padding: 8px 0; padding: 8px 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
} }
.mobile-bottom-sheet-item { .mobile-bottom-sheet-item {
@@ -3240,7 +3246,10 @@ a:active,
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
max-height: 80vh; max-height: 80vh;
max-height: 80dvh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
padding-bottom: env(safe-area-inset-bottom, 0); padding-bottom: env(safe-area-inset-bottom, 0);
} }
@@ -3602,6 +3611,7 @@ a:active,
max-width: none; max-width: none;
border-radius: 12px; border-radius: 12px;
max-height: 70vh; max-height: 70vh;
max-height: 70dvh;
} }
} }
@@ -0,0 +1,28 @@
# Constrain and Scroll Edit Dialogs and Popups
## Summary
Ensure every edit dialog, form popup, and modal popup remains usable on a viewport that is shorter than its content. Dialog chrome must stay within the visible viewport while the content area scrolls independently.
## Problem
The shared dialog system constrains only components that follow its `dialog-header` / `dialog-body` structure. Several tool-launch and commit dialogs place form content directly inside the container or use a bespoke container, so long forms can be clipped. Existing viewport sizing also relies on `vh`, which is unreliable when mobile browser chrome changes height.
## Scope
- Shared dialog and modal CSS in `apps/web/src/styles/global.css`.
- Mobile notification dropdown sizing in `apps/web/src/styles/utilities.css`.
- Tool-launch and commit popup markup that does not currently provide a scrollable content region.
## Acceptance Criteria
- [ ] Dialogs and modal popups are bounded by the current visible viewport, including mobile dynamic viewport changes.
- [ ] Headers and footer/action bars remain visible while long form content scrolls independently.
- [ ] Tool-launch and commit popups use the shared scrollable content pattern.
- [ ] Mobile sheets, action sheets, and notification dropdowns remain scrollable without propagating scroll gestures to the page.
- [ ] Relevant frontend tests, typecheck, lint, and production build pass.
## Non-Goals
- Redesigning dialog visuals or interaction flows.
- Changing page-level scrolling outside overlays.
@@ -0,0 +1,8 @@
# Constrain and Scroll Edit Dialogs and Popups — Tasks
- [x] Audit all dialog, modal, sheet, action-sheet, and popup implementations.
- [x] Strengthen shared dialog/modal viewport and body scrolling rules.
- [x] Update bespoke tool-launch and commit popups to use scrollable content regions.
- [x] Add focused coverage for the shared scrollable dialog-body markup.
- [x] Run frontend typecheck, lint, tests (88 passed), and production build.
- [x] Update project maps for changed source files.