merge: dialog scroll containment
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user