fix(ui): contain growing dialog content (#3060)

This commit is contained in:
Carlos Florêncio
2026-08-26 14:13:35 +00:00
committed by GitHub
parent 43a1ce71e4
commit 824851acbd
35 changed files with 2408 additions and 64 deletions
@@ -760,6 +760,22 @@
registry.registerKeybinding("open-demo", function () {
function DemoModalContent() {
var completedState = React.useState(false);
var completed = completedState[0];
var setCompleted = completedState[1];
var longRows = [];
for (var index = 0; index < 32; index += 1) {
longRows.push(
jsx(
"p",
{ key: "long-modal-row-" + index },
"Long plugin modal content row " +
String(index + 1) +
" keeps the opaque plugin surface growing beyond the viewport.",
),
);
}
// The Tooltip is the point of this modal in e2e: PluginModalHost
// mounts outside AppShell, so without its own TooltipProvider a
// Tooltip here throws on render and the error boundary swallows the
@@ -767,7 +783,11 @@
// hover, so real pointer hover is only assertable here.
return jsx(
"div",
{ id: "hello-demo-modal", "data-testid": "hello-demo-modal" },
{
id: "hello-demo-modal",
"data-testid": "hello-demo-modal",
style: { display: "grid", gap: "8px" },
},
"Hello from the plugin modal",
jsx(
ui.Tooltip,
@@ -775,6 +795,19 @@
jsx(ui.TooltipTrigger, { "data-testid": "hello-modal-tooltip-trigger" }, "hover me"),
jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"),
),
jsx("div", { "data-testid": "hello-long-modal-content" }, longRows),
jsx(
"button",
{
type: "button",
"data-testid": "hello-long-modal-final-action",
style: { minHeight: "44px", padding: "8px 12px" },
onClick: function () {
setCompleted(true);
},
},
completed ? "Plugin modal action complete" : "Complete plugin modal action",
),
);
}
host.openModal({
@@ -311,30 +311,48 @@ export function CreateProjectDialog({ open, onOpenChange, workspaceId }: CreateP
const { form, update, submitting, handleAddRepo, handleRemoveRepo, handleCreate } =
useProjectForm(workspaceId, () => onOpenChange(false));
// ProjectRepositoryPicker renders its popover inline. Paint clipping keeps
// that wide popover from creating a horizontal scroll range on the dialog.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogContent
data-testid="create-project-dialog"
data-layout="contained"
className="max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-clip sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>{t("office:newProject")}</DialogTitle>
</DialogHeader>
<ProjectFormBody
form={form}
agents={agents}
executorTypes={executorTypes}
workspaceId={workspaceId}
onUpdate={update}
onAddRepo={handleAddRepo}
onRemoveRepo={handleRemoveRepo}
/>
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="cursor-pointer">
<div
data-testid="create-project-dialog-body"
className="min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain"
>
<ProjectFormBody
form={form}
agents={agents}
executorTypes={executorTypes}
workspaceId={workspaceId}
onUpdate={update}
onAddRepo={handleAddRepo}
onRemoveRepo={handleRemoveRepo}
/>
</div>
<div
data-testid="create-project-dialog-footer"
className="flex flex-col justify-end gap-2 border-t border-border pt-4 sm:flex-row"
>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="min-h-11 w-full cursor-pointer sm:min-h-9 sm:w-auto"
>
{t("common:cancel")}
</Button>
<Button
onClick={handleCreate}
disabled={!form.name.trim() || submitting}
className="cursor-pointer"
className="min-h-11 w-full cursor-pointer sm:min-h-9 sm:w-auto"
>
{submitting ? t("office:creating") : t("office:createProject")}
</Button>
@@ -43,6 +43,48 @@ describe("PluginModalHost", () => {
expect(screen.getByText("Use a Bitbucket pull request URL for this task.")).not.toBeNull();
});
it("keeps dialog content in a local scroll body while retaining the close control", () => {
pluginModalManager.openModal("plugin-a", {
title: "Growing modal",
content: () => <div data-testid="modal-content">Long plugin content</div>,
});
render(<PluginModalHost />);
const dialog = screen.getByTestId(/^plugin-modal-dialog-/);
const body = screen.getByTestId(/^plugin-modal-body-/);
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(dialog.contains(body)).toBe(true);
expect(screen.getByRole("button", { name: "Close" })).not.toBeNull();
});
it("keeps titleless task-link content in the bounded scroll row", () => {
pluginModalManager.openTaskLinkDialog("plugin-a", {
content: () => <div data-testid="modal-content">Long task-link content</div>,
});
render(<PluginModalHost />);
const dialog = screen.getByTestId(/^plugin-modal-dialog-/);
const body = screen.getByTestId(/^plugin-modal-body-/);
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(body.className).toContain("row-start-2");
expect(screen.getByRole("dialog", { name: "Plugin dialog" })).toBeTruthy();
});
it("does not add a close control to a nondismissible dialog", () => {
pluginModalManager.openModal("plugin-a", {
title: "Locked modal",
content: () => <div>Content</div>,
dismissible: false,
});
render(<PluginModalHost />);
expect(screen.getByTestId(/^plugin-modal-dialog-/)).not.toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
});
it("renders a host-owned drawer when the plugin requests mobile presentation", () => {
pluginModalManager.openModal("plugin-a", {
title: "Link pull request",
@@ -32,6 +32,9 @@ const SIZE_CLASSES: Record<NonNullable<PluginModalOptions["size"]>, string> = {
xl: "sm:max-w-5xl",
};
const DIALOG_CONTAINMENT_CLASSES =
"max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)] overflow-hidden";
function preventWhenNotDismissible(dismissible: boolean) {
return (event: Event) => {
if (!dismissible) event.preventDefault();
@@ -90,10 +93,12 @@ function PluginDialog({ modal, dismissible, onOpenChange }: ModalSurfaceProps) {
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
{...noDescriptionProps}
data-testid={`plugin-modal-dialog-${instanceId}`}
data-layout="contained"
className={
modal.layout === "task-link"
? "w-[calc(100vw-2rem)] sm:max-w-lg"
: SIZE_CLASSES[options.size ?? "md"]
? `${DIALOG_CONTAINMENT_CLASSES} w-[calc(100vw-2rem)] sm:max-w-lg`
: `${DIALOG_CONTAINMENT_CLASSES} ${SIZE_CLASSES[options.size ?? "md"]}`
}
showCloseButton={dismissible}
onEscapeKeyDown={guardClose}
@@ -113,9 +118,14 @@ function PluginDialog({ modal, dismissible, onOpenChange }: ModalSurfaceProps) {
{!options.title && !options.description && (
<DialogTitle className="sr-only">{pluginDialogLabel()}</DialogTitle>
)}
<PluginErrorBoundary context={`modal "${instanceId}" (plugin "${pluginId}")`}>
<Content />
</PluginErrorBoundary>
<div
data-testid={`plugin-modal-body-${instanceId}`}
className="row-start-2 min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain"
>
<PluginErrorBoundary context={`modal "${instanceId}" (plugin "${pluginId}")`}>
<Content />
</PluginErrorBoundary>
</div>
</DialogContent>
</Dialog>
);
@@ -241,3 +241,23 @@ describe("AgentProfileDeleteConflictDialog", () => {
expect(screen.queryByText(/Delete agent profile/i)).toBeNull();
});
});
describe("AgentProfileDeleteConflictDialog layout", () => {
it("keeps conflict details scrollable and decisions outside the scroll region", () => {
renderConflictDialog({
activeSessions: [{ task_id: "t1", task_title: "Live task", is_ephemeral: false }],
watchers: [],
routingTiers: [],
automations: [],
});
const dialog = screen.getByTestId("agent-profile-delete-conflict-dialog");
const body = screen.getByTestId("agent-profile-delete-conflict-body");
const footer = screen.getByTestId("agent-profile-delete-conflict-footer");
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(dialog.contains(body)).toBe(true);
expect(body.contains(footer)).toBe(false);
expect(within(footer).getByRole("button", { name: "Cancel" })).toBeTruthy();
});
});
@@ -171,49 +171,59 @@ export function AgentProfileDeleteConflictDialog({
return (
<AlertDialog open={!!conflict} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogContent
data-testid="agent-profile-delete-conflict-dialog"
data-layout="contained"
className="max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
>
<AlertDialogHeader>
<AlertDialogTitle>
{hasHardBlockers ? t("agents:cannotDeleteAgentProfile") : t(DELETE_PROFILE_TITLE_KEY)}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>{t("agents:profileInUseIntro")}</p>
<SessionConflictSection
title={t("agents:conflictTasksTitle")}
sessions={tasks}
fallback={t("agents:untitledTask")}
/>
<SessionConflictSection
title={t("agents:conflictQuickChatsTitle")}
sessions={quickChats}
fallback={t("agents:untitledQuickChat")}
/>
<WatcherConflictSection watchersByKind={watchersByKind} />
<RoutingTierConflictSection
routingTiers={routingTiers}
workspaceLabels={new Map(workspaces.map((w) => [w.id, w.name]))}
providerLabels={new Map(providers.map((p) => [p.id, p.name]))}
/>
<AutomationConflictSection
automations={automations}
workspaceLabels={new Map(workspaces.map((w) => [w.id, w.name]))}
/>
<UtilityAgentConflictSection utilityAgents={utilityAgents} />
{hasHardBlockers ? (
<p className="mt-2">{t("agents:changeTierMappingsFirst")}</p>
) : (
<p className="mt-2">{t("agents:deleteAnywayConsequences")}</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t(CANCEL_LABEL_KEY)}</AlertDialogCancel>
<AlertDialogDescription
asChild
data-testid="agent-profile-delete-conflict-body"
className="min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain text-left"
>
<div>
<p>{t("agents:profileInUseIntro")}</p>
<SessionConflictSection
title={t("agents:conflictTasksTitle")}
sessions={tasks}
fallback={t("agents:untitledTask")}
/>
<SessionConflictSection
title={t("agents:conflictQuickChatsTitle")}
sessions={quickChats}
fallback={t("agents:untitledQuickChat")}
/>
<WatcherConflictSection watchersByKind={watchersByKind} />
<RoutingTierConflictSection
routingTiers={routingTiers}
workspaceLabels={new Map(workspaces.map((w) => [w.id, w.name]))}
providerLabels={new Map(providers.map((p) => [p.id, p.name]))}
/>
<AutomationConflictSection
automations={automations}
workspaceLabels={new Map(workspaces.map((w) => [w.id, w.name]))}
/>
<UtilityAgentConflictSection utilityAgents={utilityAgents} />
{hasHardBlockers ? (
<p className="mt-2">{t("agents:changeTierMappingsFirst")}</p>
) : (
<p className="mt-2">{t("agents:deleteAnywayConsequences")}</p>
)}
</div>
</AlertDialogDescription>
<AlertDialogFooter data-testid="agent-profile-delete-conflict-footer">
<AlertDialogCancel className="min-h-11 w-full cursor-pointer sm:min-h-9 sm:w-auto">
{t(CANCEL_LABEL_KEY)}
</AlertDialogCancel>
{hasHardBlockers ? null : (
<AlertDialogAction
onClick={onConfirm}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="min-h-11 w-full cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:min-h-9 sm:w-auto"
>
{t("agents:deleteAnyway")}
</AlertDialogAction>
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MarketplaceSourcesDialog } from "./marketplace-sources-dialog";
import type { MarketplaceSource } from "@/lib/types/plugins";
const marketplaceApi = vi.hoisted(() => ({
addMarketplaceSource: vi.fn(),
deleteMarketplaceSource: vi.fn(),
updateMarketplaceSource: vi.fn(),
}));
vi.mock("@/lib/api/domains/marketplace-api", () => marketplaceApi);
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
marketplaceApi.deleteMarketplaceSource.mockResolvedValue({ deleted: true });
marketplaceApi.updateMarketplaceSource.mockResolvedValue({});
});
function source(overrides: Partial<MarketplaceSource> = {}): MarketplaceSource {
return {
id: "team",
name: "Team Registry",
url: "https://registry.example/team/index.json",
enabled: true,
builtin: false,
healthy: true,
...overrides,
};
}
function renderDialog(sources: MarketplaceSource[] = [source()]) {
return render(
<MarketplaceSourcesDialog open sources={sources} onOpenChange={vi.fn()} onChanged={vi.fn()} />,
);
}
describe("MarketplaceSourcesDialog", () => {
it("keeps source actions wired to their source while preserving the add form", async () => {
renderDialog();
expect(screen.getByTestId("marketplace-source-team")).toBeTruthy();
expect(screen.getByTestId("marketplace-add-source-name")).toBeTruthy();
expect(screen.getByTestId("marketplace-add-source-url")).toBeTruthy();
fireEvent.click(screen.getByRole("switch"));
await waitFor(() =>
expect(marketplaceApi.updateMarketplaceSource).toHaveBeenCalledWith("team", {
enabled: false,
}),
);
fireEvent.click(screen.getByRole("button", { name: /Remove/i }));
await waitFor(() =>
expect(marketplaceApi.deleteMarketplaceSource).toHaveBeenCalledWith("team"),
);
});
it("exposes one scroll body with the add form and touch-sized row actions outside it", () => {
renderDialog([
source({ id: "official", name: "Official", builtin: true }),
source({ id: "team-2", name: "Team Two" }),
]);
const dialog = screen.getByTestId("marketplace-sources-dialog");
const sourceList = screen.getByTestId("marketplace-sources-list");
const addForm = screen.getByTestId("marketplace-add-source-form");
const remove = within(screen.getByTestId("marketplace-source-team-2")).getByRole("button", {
name: /Remove/i,
});
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(dialog.contains(sourceList)).toBe(true);
expect(sourceList.contains(addForm)).toBe(false);
expect(remove).toBeTruthy();
});
});
@@ -43,7 +43,11 @@ export function MarketplaceSourcesDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent
data-testid="marketplace-sources-dialog"
data-layout="contained"
className="max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
>
<DialogHeader>
<DialogTitle>{t("plugins:marketplaceSources")}</DialogTitle>
<DialogDescription>
@@ -52,7 +56,10 @@ export function MarketplaceSourcesDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<div
data-testid="marketplace-sources-list"
className="min-h-0 min-w-0 space-y-2 overflow-x-hidden overflow-y-auto overscroll-contain"
>
{sources.map((source) => (
<SourceItem key={source.id} source={source} onChanged={onChanged} />
))}
@@ -121,7 +128,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
size="icon"
disabled={busy}
onClick={remove}
className="cursor-pointer"
className="min-h-11 min-w-11 cursor-pointer sm:min-h-0 sm:min-w-0"
aria-label={t("plugins:removeSource", { name: source.name })}
>
<IconTrash className="h-4 w-4" />
@@ -154,24 +161,29 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
};
return (
<div className="space-y-2 border-t border-border/60 pt-3">
<div
data-testid="marketplace-add-source-form"
className="space-y-2 border-t border-border/60 pt-3"
>
<Input
placeholder={t("plugins:sourceNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="min-h-11 sm:min-h-7"
data-testid="marketplace-add-source-name"
/>
<div className="flex items-center gap-2">
<Input
placeholder="https://.../index.json"
placeholder={t("plugins:sourceUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
className="min-h-11 sm:min-h-7"
data-testid="marketplace-add-source-url"
/>
<Button
disabled={busy || !url.trim()}
onClick={submit}
className="cursor-pointer shrink-0"
className="min-h-11 cursor-pointer shrink-0 sm:min-h-0"
data-testid="marketplace-add-source-submit"
>
{t("plugins:add")}
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { HealthIssuesDialog } from "./health-indicator";
import type { HealthIssue } from "@/lib/types/health";
const router = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => router,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ workspaces: { activeId: "workspace-1" } }),
}));
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
});
function issue(overrides: Partial<HealthIssue> = {}): HealthIssue {
return {
id: "disk-full",
category: "storage",
title: "Disk space is low",
message: "Free some space before starting another task.",
severity: "warning",
fix_url: "/settings/system/status",
fix_label: "View system status",
...overrides,
};
}
function renderDialog(issues: HealthIssue[] = [issue()]) {
return render(<HealthIssuesDialog open onOpenChange={vi.fn()} issues={issues} />);
}
describe("HealthIssuesDialog", () => {
it("keeps issue Fix navigation unchanged", () => {
const onOpenChange = vi.fn();
render(<HealthIssuesDialog open onOpenChange={onOpenChange} issues={[issue()]} />);
fireEvent.click(screen.getByRole("button", { name: "View system status" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(router.push).toHaveBeenCalledWith("/settings/system/status");
});
it("exposes one scroll body for issue cards while keeping the dialog shell local", () => {
renderDialog([
issue({ id: "disk-full" }),
issue({ id: "git-missing", title: "Git is missing" }),
]);
const dialog = screen.getByTestId("system-health-issues-dialog");
const body = screen.getByTestId("system-health-issues-body");
expect(dialog.getAttribute("data-layout")).toBe("contained");
expect(dialog.contains(body)).toBe(true);
expect(body.textContent).toContain("Disk space is low");
});
});
@@ -59,7 +59,11 @@ export function HealthIssuesDialog({ open, onOpenChange, issues }: HealthIssuesD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogContent
data-testid="system-health-issues-dialog"
data-layout="contained"
className="max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)] overflow-hidden sm:max-w-[480px]"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
@@ -69,15 +73,22 @@ export function HealthIssuesDialog({ open, onOpenChange, issues }: HealthIssuesD
{t("system:issuesNeedAttention", { count: issues.length })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
<div
data-testid="system-health-issues-body"
className="min-h-0 min-w-0 space-y-4 overflow-x-hidden overflow-y-auto overscroll-contain pt-2"
>
{issues.map((issue) => (
<div key={issue.id} className="rounded-lg border p-3 space-y-2">
<div
key={issue.id}
data-testid={`system-health-issue-${issue.id}`}
className="rounded-lg border p-3 space-y-2"
>
<div className="font-medium text-sm">{issue.title}</div>
<div className="text-muted-foreground text-xs">{issue.message}</div>
<Button
variant="outline"
size="sm"
className="cursor-pointer h-7 text-xs"
className="min-h-11 cursor-pointer h-7 text-xs sm:min-h-0"
onClick={() => {
onOpenChange(false);
router.push(resolveUrl(issue.fix_url));
@@ -0,0 +1,130 @@
import { expect, test } from "../fixtures/test-base";
import { MobileKanbanPage } from "../pages/mobile-kanban-page";
const FINAL_ISSUE_ID = "health-23";
function longHealthResponse() {
return {
healthy: false,
issues: Array.from({ length: 24 }, (_, index) => ({
id: `health-${index}`,
category: "system_resources",
title: `System health issue ${index}`,
message: `The diagnostic message for issue ${index} needs operator attention.`,
severity: index % 2 === 0 ? "warning" : "error",
fix_url: "/settings/system/status",
fix_label: `Fix issue ${index}`,
})),
checks: [],
};
}
test.describe("System health issue dialog on mobile", () => {
test("keeps issue cards and 44px Fix actions inside the dialog", async ({
testPage,
backend,
}) => {
test.setTimeout(60_000);
await testPage.route(`${backend.baseUrl}/api/v1/system/health`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(longHealthResponse()),
}),
);
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileMenuButton.click();
await testPage.getByRole("button", { name: "Health issues" }).tap();
const dialog = testPage.getByTestId("system-health-issues-dialog");
await expect(dialog).toBeVisible();
await dialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") {
return false;
}
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const body = dialog.getByTestId("system-health-issues-body");
const title = dialog.locator('[data-slot="dialog-title"]');
const count = dialog.locator('[data-slot="dialog-description"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalIssue = body.getByTestId(`system-health-issue-${FINAL_ISSUE_ID}`);
const finalFix = finalIssue.getByRole("button", { name: "Fix issue 23" });
await expect(finalIssue).toHaveCount(1);
const scrollable = await body.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
const dialogBox = await dialog.boundingBox();
const titleBox = await title.boundingBox();
const countBox = await count.boundingBox();
const closeBox = await close.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(countBox).not.toBeNull();
expect(closeBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(countBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(countBox!.y + countBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await finalIssue.scrollIntoViewIfNeeded();
await expect(finalFix).toBeVisible();
const finalIssueBox = await finalIssue.boundingBox();
const finalFixBox = await finalFix.boundingBox();
expect(finalIssueBox).not.toBeNull();
expect(finalFixBox).not.toBeNull();
expect(finalIssueBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalIssueBox!.y + finalIssueBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(finalFixBox!.height).toBeGreaterThanOrEqual(44);
expect(finalFixBox!.y).toBeGreaterThanOrEqual(finalIssueBox!.y);
expect(finalFixBox!.y + finalFixBox!.height).toBeLessThanOrEqual(
finalIssueBox!.y + finalIssueBox!.height,
);
await expect
.poll(async () =>
finalFix.evaluate((element) => {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return hit === element || element.contains(hit);
}),
)
.toBe(true);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await finalFix.tap();
await expect(dialog).not.toBeVisible();
await expect(testPage).toHaveURL(/\/settings\/system\/status$/);
});
});
@@ -0,0 +1,119 @@
/**
* Mobile-Chrome geometry coverage for the Office Create Project dialog.
* Repository chips are added through the same picker used by the desktop
* dialog, so this covers both form growth and the real selection state path.
*/
import type { Locator } from "@playwright/test";
import { expect, test } from "../../fixtures/office-fixture";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
async function addCustomRepository(dialog: Locator, value: string): Promise<void> {
await dialog.getByTestId("project-add-repository").tap();
const searchInput = dialog.getByPlaceholder(/Search or paste a URL/i);
await expect(searchInput).toBeVisible();
await searchInput.fill(value);
const customRow = dialog.getByTestId("project-add-custom");
await expect(customRow).toBeVisible({ timeout: 5_000 });
await customRow.tap();
}
test.describe("Mobile Create Project dialog", () => {
test("keeps long repository chips and footer controls inside the phone viewport", async ({
testPage,
prCapture,
officeSeed: _,
}) => {
test.setTimeout(120_000);
await testPage.goto("/office/projects");
await testPage
.getByRole("button", { name: /New Project|Create your first project/ })
.first()
.tap();
const dialog = testPage.getByTestId("create-project-dialog");
await expect(dialog).toBeVisible();
await dialog.locator("#project-name").fill(`Mobile Long Picker ${Date.now()}`);
const repositories = Array.from(
{ length: 14 },
(_, index) => `https://github.com/example/mobile-overflow-repository-${index}.git`,
);
for (const repository of repositories) {
await addCustomRepository(dialog, repository);
}
const body = dialog.getByTestId("create-project-dialog-body");
const footer = dialog.getByTestId("create-project-dialog-footer");
const title = dialog.locator('[data-slot="dialog-title"]');
const finalChip = body.locator('[data-testid="project-repo-chip"]', {
hasText: repositories.at(-1) ?? "",
});
const finalRemove = finalChip.getByTestId("project-repo-chip-remove");
const cancel = footer.getByRole("button", { name: "Cancel" });
const create = footer.getByRole("button", { name: "Create Project" });
await expect(body).toHaveCount(1);
await expect(footer).toHaveCount(1);
await expect(finalChip).toBeVisible();
const bodyMetrics = await body.evaluate((element) => {
const node = element as HTMLElement;
return { clientHeight: node.clientHeight, scrollHeight: node.scrollHeight };
});
expect(bodyMetrics.scrollHeight).toBeGreaterThan(bodyMetrics.clientHeight);
const footerOutsideBody = await dialog.evaluate((element) => {
const bodyNode = element.querySelector('[data-testid="create-project-dialog-body"]');
const footerNode = element.querySelector('[data-testid="create-project-dialog-footer"]');
return Boolean(bodyNode && footerNode && !bodyNode.contains(footerNode));
});
expect(footerOutsideBody).toBe(true);
const viewport = await testPage.evaluate(() => ({ width: innerWidth, height: innerHeight }));
for (const [label, locator] of [
["dialog", dialog],
["title", title],
["footer", footer],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.x, `${label} left edge`).toBeGreaterThanOrEqual(0);
expect(box.y, `${label} top edge`).toBeGreaterThanOrEqual(0);
expect(box.x + box.width, `${label} right edge`).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height, `${label} bottom edge`).toBeLessThanOrEqual(viewport.height);
}
for (const [label, locator] of [
["Cancel", cancel],
["Create Project", create],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.height).toBeGreaterThanOrEqual(44);
}
await body.evaluate((element) => {
const node = element as HTMLElement;
node.scrollTop = node.scrollHeight;
});
await expect
.poll(() => body.evaluate((element) => (element as HTMLElement).scrollTop))
.toBeGreaterThan(0);
await expect(finalChip).toBeInViewport();
await expect(finalRemove).toBeInViewport();
await expect(cancel).toBeInViewport();
await expect(create).toBeInViewport();
if (prCapture.capturing) {
await prCapture.screenshot("long-create-project-dialog", {
caption:
"The mobile Create Project form keeps the repository body and action footer usable.",
});
}
await finalRemove.tap();
await expect(finalChip).toHaveCount(0);
await cancel.tap();
await expect(dialog).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage, "mobile create-project dialog");
});
});
@@ -1,8 +1,10 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../../fixtures/office-fixture";
import { makeGitEnv } from "../../helpers/git-helper";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
/**
* Project page — repository picker (chip-style, popover-driven).
@@ -32,6 +34,18 @@ async function createProject(
return (body.project?.id ?? body.id) as string;
}
async function addCustomRepository(dialog: Locator, value: string): Promise<void> {
await dialog.getByTestId("project-add-repository").click();
const searchInput = dialog.getByPlaceholder(/Search or paste a URL/i);
await expect(searchInput).toBeVisible();
await searchInput.fill(value);
const customRow = dialog.getByTestId("project-add-custom");
await expect(customRow).toBeVisible({ timeout: 5_000 });
const customRowBox = await customRow.boundingBox();
if (!customRowBox) throw new Error("custom repository option has no layout box");
await customRow.click({ position: { x: 24, y: customRowBox.height / 2 } });
}
test.describe("Project repository picker", () => {
test("paste a remote URL → chip appears → remove → empty state returns", async ({
apiClient,
@@ -177,4 +191,101 @@ test.describe("Project repository picker", () => {
const detailChip = testPage.locator('[data-testid="project-repo-chip"]', { hasText: url });
await expect(detailChip).toBeVisible({ timeout: 10_000 });
});
test("create-project dialog: long repository chips keep the footer reachable", async ({
testPage,
prCapture,
officeSeed: _,
}) => {
await testPage.goto("/office/projects");
await testPage
.getByRole("button", { name: /New Project|Create your first project/ })
.first()
.click();
const dialog = testPage.getByTestId("create-project-dialog");
await expect(dialog).toBeVisible();
await dialog.locator("#project-name").fill(`Long Picker Create ${Date.now()}`);
const repositories = Array.from(
{ length: 18 },
(_, index) => `https://github.com/example/overflow-repository-${index}.git`,
);
for (const repository of repositories) {
await addCustomRepository(dialog, repository);
}
const body = dialog.getByTestId("create-project-dialog-body");
const footer = dialog.getByTestId("create-project-dialog-footer");
const title = dialog.locator('[data-slot="dialog-title"]');
const finalChip = body.locator('[data-testid="project-repo-chip"]', {
hasText: repositories.at(-1) ?? "",
});
const finalRemove = finalChip.getByTestId("project-repo-chip-remove");
const cancel = footer.getByRole("button", { name: "Cancel" });
const create = footer.getByRole("button", { name: "Create Project" });
await expect(body).toHaveCount(1);
await expect(footer).toHaveCount(1);
await expect(finalChip).toBeVisible();
const bodyMetrics = await body.evaluate((element) => {
const node = element as HTMLElement;
return { clientHeight: node.clientHeight, scrollHeight: node.scrollHeight };
});
expect(bodyMetrics.scrollHeight).toBeGreaterThan(bodyMetrics.clientHeight);
const footerOutsideBody = await dialog.evaluate((element) => {
const bodyNode = element.querySelector('[data-testid="create-project-dialog-body"]');
const footerNode = element.querySelector('[data-testid="create-project-dialog-footer"]');
return Boolean(bodyNode && footerNode && !bodyNode.contains(footerNode));
});
expect(footerOutsideBody).toBe(true);
const viewport = await testPage.evaluate(() => ({ width: innerWidth, height: innerHeight }));
for (const [label, locator] of [
["dialog", dialog],
["title", title],
["footer", footer],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.x, `${label} left edge`).toBeGreaterThanOrEqual(0);
expect(box.y, `${label} top edge`).toBeGreaterThanOrEqual(0);
expect(box.x + box.width, `${label} right edge`).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height, `${label} bottom edge`).toBeLessThanOrEqual(viewport.height);
}
await body.evaluate((element) => {
const node = element as HTMLElement;
node.scrollTop = node.scrollHeight;
});
await expect
.poll(() => body.evaluate((element) => (element as HTMLElement).scrollTop))
.toBeGreaterThan(0);
await expect(finalChip).toBeInViewport();
await expect(cancel).toBeInViewport();
await expect(create).toBeInViewport();
const footerHitTest = await footer.locator("button").evaluateAll((buttons) =>
buttons.every((button) => {
const rect = button.getBoundingClientRect();
const hit = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return hit === button || button.contains(hit);
}),
);
expect(footerHitTest).toBe(true);
if (prCapture.capturing) {
await prCapture.screenshot("long-create-project-dialog", {
caption:
"The Create Project form scrolls repository chips while its title, final chip, and action footer remain visible.",
});
}
await finalRemove.click();
await expect(finalChip).toHaveCount(0);
await cancel.click();
await expect(dialog).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage, "create-project dialog");
});
});
@@ -0,0 +1,89 @@
/**
* Mobile-Chrome geometry coverage for opaque content rendered by
* `PluginModalHost`. The fixture is installed through the real upload flow so
* the test exercises the packaged `host.openModal` contract on a phone.
*/
import { expect, test } from "../../fixtures/test-base";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
test.describe("Mobile plugin modal content", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("keeps long plugin content scrollable and dismissible", async ({ testPage, prCapture }) => {
test.setTimeout(120_000);
await installFixturePlugin(testPage);
await testPage.goto("/");
await testPage.reload();
await testPage.goto("/plugins/e2e-hello");
await expect(testPage.locator("#hello-plugin-page")).toBeVisible({ timeout: 15_000 });
await testPage.keyboard.press("ControlOrMeta+Shift+J");
const dialog = testPage.getByRole("dialog", { name: "Demo Modal" });
const body = dialog.locator('[data-testid^="plugin-modal-body-"]');
const title = dialog.locator('[data-slot="dialog-title"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalAction = dialog.getByTestId("hello-long-modal-final-action");
await expect(dialog).toBeVisible();
await expect(body).toHaveCount(1);
await expect(finalAction).toBeVisible();
await dialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") return false;
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const metrics = await body.evaluate((element) => {
const node = element as HTMLElement;
return { clientHeight: node.clientHeight, scrollHeight: node.scrollHeight };
});
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
const viewport = await testPage.evaluate(() => ({ width: innerWidth, height: innerHeight }));
for (const [label, locator] of [
["dialog", dialog],
["title", title],
["close", close],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
}
await body.evaluate((element) => {
const node = element as HTMLElement;
node.scrollTop = node.scrollHeight;
});
await expect
.poll(() => body.evaluate((element) => (element as HTMLElement).scrollTop))
.toBeGreaterThan(0);
await expect(finalAction).toBeInViewport();
const finalBox = await finalAction.boundingBox();
if (!finalBox) throw new Error("final plugin action has no layout box");
expect(finalBox.y).toBeGreaterThanOrEqual(0);
expect(finalBox.y + finalBox.height).toBeLessThanOrEqual(viewport.height);
await finalAction.tap();
await expect(finalAction).toHaveText("Plugin modal action complete");
if (prCapture.capturing) {
await prCapture.screenshot("long-plugin-modal", {
caption:
"Mobile plugin modal content stays inside one scroll body with a reachable final action.",
});
}
await close.tap();
await expect(dialog).not.toBeVisible();
const overflow = await testPage.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
);
expect(overflow).toBe(false);
});
});
@@ -616,6 +616,86 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect(modal).not.toBeVisible();
});
test("long plugin modal content stays contained and its final control remains operable", async ({
testPage,
prCapture,
}) => {
test.setTimeout(60_000);
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
await expect(
testPage.getByTestId(`plugin-row-${PLUGIN_ID}`).getByText("Active", { exact: true }),
).toBeVisible({ timeout: 30_000 });
await testPage.goto("/");
await testPage.reload();
await waitForPluginBundleReady(testPage);
await testPage.keyboard.press("ControlOrMeta+Shift+J");
const dialog = testPage.getByRole("dialog", { name: "Demo Modal" });
const body = dialog.locator('[data-testid^="plugin-modal-body-"]');
const title = dialog.locator('[data-slot="dialog-title"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalAction = dialog.getByTestId("hello-long-modal-final-action");
await expect(dialog).toBeVisible();
await expect(body).toHaveCount(1);
await expect(finalAction).toBeVisible();
await dialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") return false;
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const metrics = await body.evaluate((element) => {
const node = element as HTMLElement;
return { clientHeight: node.clientHeight, scrollHeight: node.scrollHeight };
});
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
const viewport = await testPage.evaluate(() => ({ width: innerWidth, height: innerHeight }));
for (const [label, locator] of [
["dialog", dialog],
["title", title],
["close", close],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
}
await body.evaluate((element) => {
const node = element as HTMLElement;
node.scrollTop = node.scrollHeight;
});
await expect
.poll(() => body.evaluate((element) => (element as HTMLElement).scrollTop))
.toBeGreaterThan(0);
await expect(finalAction).toBeInViewport();
const finalBox = await finalAction.boundingBox();
if (!finalBox) throw new Error("final plugin action has no layout box");
expect(finalBox.y).toBeGreaterThanOrEqual(0);
expect(finalBox.y + finalBox.height).toBeLessThanOrEqual(viewport.height);
await finalAction.click();
await expect(finalAction).toHaveText("Plugin modal action complete");
if (prCapture.capturing) {
await prCapture.screenshot("long-plugin-modal", {
caption:
"Long plugin modal content remains scrollable while the title, close control, and final action stay usable.",
});
}
await close.click();
await expect(dialog).not.toBeVisible();
});
// `PluginModalHost` owns a TooltipProvider so plugin modal content remains
// safe in both AppShell and isolated mounts. The unit test for this asserts
// via focus, because jsdom does not
@@ -9,6 +9,20 @@ async function openInitialDeleteConfirmation(page: Page) {
return confirmation;
}
function longProfileDeleteConflict() {
return {
active_sessions: Array.from({ length: 40 }, (_, index) => ({
task_id: `overflow-task-${index}`,
task_title: `Overflow task ${index}`,
is_ephemeral: false,
})),
watchers: [],
routing_tiers: [],
automations: [],
utility_agents: [],
};
}
test.describe("Agent profile deletion", () => {
test("deleting profile with no active sessions shows confirm dialog then succeeds", async ({
testPage,
@@ -251,4 +265,111 @@ test.describe("Agent profile deletion", () => {
// Should redirect to agents settings page
await expect(testPage).toHaveURL(/\/settings\/agents$/, { timeout: 15_000 });
});
test("keeps conflict actions visible with many tasks", async ({ testPage, apiClient }) => {
test.setTimeout(60_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
const profile = await apiClient.createAgentProfile(agent.id, "Overflow Profile", {
model: agent.profiles[0].model,
});
await testPage.route(`**/api/v1/agent-profiles/${profile.id}**`, async (route) => {
if (route.request().method() !== "DELETE") {
await route.continue();
return;
}
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify(longProfileDeleteConflict()),
});
});
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
await expect(testPage.getByText("Delete profile", { exact: true })).toBeVisible({
timeout: 15_000,
});
const confirmDialog = await openInitialDeleteConfirmation(testPage);
await confirmDialog.getByTestId("agent-profile-delete-confirm").click();
const conflictDialog = testPage.getByTestId("agent-profile-delete-conflict-dialog");
await expect(conflictDialog).toBeVisible({ timeout: 10_000 });
await conflictDialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") {
return false;
}
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const body = conflictDialog.getByTestId("agent-profile-delete-conflict-body");
const footer = conflictDialog.getByTestId("agent-profile-delete-conflict-footer");
const title = conflictDialog.locator('[data-slot="alert-dialog-title"]');
const finalTask = body.getByText("Overflow task 39", { exact: true });
const scrollable = await body.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const dialogBox = await conflictDialog.boundingBox();
const titleBox = await title.boundingBox();
const footerBox = await footer.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(footerBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
const finalTaskBox = await finalTask.boundingBox();
const footerAfterBox = await footer.boundingBox();
expect(finalTaskBox).not.toBeNull();
expect(footerAfterBox).not.toBeNull();
expect(finalTaskBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalTaskBox!.y + finalTaskBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(footerAfterBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(footerAfterBox!.y + footerAfterBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
for (const label of ["Cancel", "Delete Anyway"]) {
const action = conflictDialog.getByRole("button", { name: label, exact: true });
await expect(action).toBeVisible();
await expect
.poll(async () =>
action.evaluate((element) => {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return hit === element || element.contains(hit);
}),
)
.toBe(true);
}
await conflictDialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(conflictDialog).not.toBeVisible();
await apiClient.deleteAgentProfile(profile.id, true);
});
});
@@ -1,5 +1,19 @@
import { test, expect } from "../../fixtures/test-base";
function longProfileDeleteConflict() {
return {
active_sessions: Array.from({ length: 40 }, (_, index) => ({
task_id: `overflow-task-${index}`,
task_title: `Overflow task ${index}`,
is_ephemeral: false,
})),
watchers: [],
routing_tiers: [],
automations: [],
utility_agents: [],
};
}
test.describe("Agent profile deletion on mobile", () => {
test("keeps list-row deletion controls above the row link and returns focus on cancel", async ({
testPage,
@@ -96,4 +110,123 @@ test.describe("Agent profile deletion on mobile", () => {
await expect(testPage).toHaveURL(/\/settings\/agents$/, { timeout: 15_000 });
});
test("keeps conflict actions visible with many tasks", async ({ testPage, apiClient }) => {
test.setTimeout(60_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
const profile = await apiClient.createAgentProfile(agent.id, "Mobile Overflow Profile", {
model: agent.profiles[0].model,
});
await testPage.route(`**/api/v1/agent-profiles/${profile.id}**`, async (route) => {
if (route.request().method() !== "DELETE") {
await route.continue();
return;
}
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify(longProfileDeleteConflict()),
});
});
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
await expect(testPage.getByText("Delete profile", { exact: true })).toBeVisible({
timeout: 15_000,
});
await testPage.getByTestId("profile-delete-trigger").tap();
const confirmation = testPage.getByTestId("agent-profile-delete-inline-confirmation");
await expect(confirmation).toBeVisible();
await confirmation.getByTestId("agent-profile-delete-confirm").tap();
const conflictDialog = testPage.getByTestId("agent-profile-delete-conflict-dialog");
await expect(conflictDialog).toBeVisible({ timeout: 10_000 });
await conflictDialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") {
return false;
}
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const body = conflictDialog.getByTestId("agent-profile-delete-conflict-body");
const footer = conflictDialog.getByTestId("agent-profile-delete-conflict-footer");
const title = conflictDialog.locator('[data-slot="alert-dialog-title"]');
const finalTask = body.getByText("Overflow task 39", { exact: true });
const scrollable = await body.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
const dialogBox = await conflictDialog.boundingBox();
const titleBox = await title.boundingBox();
const footerBox = await footer.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(footerBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
const finalTaskBox = await finalTask.boundingBox();
expect(finalTaskBox).not.toBeNull();
expect(finalTaskBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalTaskBox!.y + finalTaskBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
const footerAfterBox = await footer.boundingBox();
expect(footerAfterBox).not.toBeNull();
expect(footerAfterBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(footerAfterBox!.y + footerAfterBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
for (const label of ["Cancel", "Delete Anyway"]) {
const action = conflictDialog.getByRole("button", { name: label, exact: true });
const box = await action.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
await expect
.poll(async () =>
action.evaluate((element) => {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return hit === element || element.contains(hit);
}),
)
.toBe(true);
}
await conflictDialog.getByRole("button", { name: "Cancel", exact: true }).tap();
await expect(conflictDialog).not.toBeVisible();
const documentOverflow = await testPage.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(documentOverflow.scrollWidth).toBeLessThanOrEqual(documentOverflow.clientWidth);
await apiClient.deleteAgentProfile(profile.id, true);
});
});
@@ -0,0 +1,116 @@
import { expect, test } from "../../fixtures/test-base";
const FINAL_SOURCE_ID = "source-31";
function longMarketplaceCatalog() {
return {
plugins: [],
sources: Array.from({ length: 32 }, (_, index) => ({
id: `source-${index}`,
name: `Registry ${index}`,
url: `https://registry-${index}.example/index.json`,
enabled: true,
builtin: index === 0,
healthy: true,
created_at: "2026-01-01T00:00:00.000Z",
})),
};
}
test.describe("Marketplace source dialog on mobile", () => {
test("keeps source rows and touch actions inside a contained dialog", async ({ testPage }) => {
test.setTimeout(60_000);
await testPage.route("**/api/plugins/marketplace*", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(longMarketplaceCatalog()),
});
});
await testPage.goto("/settings/plugins");
await testPage.getByTestId("plugins-tab-browse").tap();
await expect(testPage.getByTestId("marketplace-manage-sources")).toBeVisible();
await testPage.getByTestId("marketplace-manage-sources").tap();
const dialog = testPage.getByTestId("marketplace-sources-dialog");
await expect(dialog).toBeVisible();
const list = dialog.getByTestId("marketplace-sources-list");
const addForm = dialog.getByTestId("marketplace-add-source-form");
const title = dialog.locator('[data-slot="dialog-title"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalSource = list.getByTestId(`marketplace-source-${FINAL_SOURCE_ID}`);
const scrollable = await list.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
const dialogBox = await dialog.boundingBox();
const titleBox = await title.boundingBox();
const closeBox = await close.boundingBox();
const addFormBox = await addForm.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(closeBox).not.toBeNull();
expect(addFormBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(addFormBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(addFormBox!.y + addFormBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
await expect(dialog.getByTestId("marketplace-add-source-url")).toBeVisible();
await expect(dialog.getByTestId("marketplace-add-source-submit")).toBeVisible();
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await expect(finalSource).toBeVisible();
const finalSourceBox = await finalSource.boundingBox();
const finalSwitch = finalSource.getByRole("switch");
const finalRemove = finalSource.getByRole("button", { name: /Remove/i });
const finalSwitchBox = await finalSwitch.boundingBox();
const finalRemoveBox = await finalRemove.boundingBox();
const addFormAfterBox = await addForm.boundingBox();
expect(finalSourceBox).not.toBeNull();
expect(finalSwitchBox).not.toBeNull();
expect(finalRemoveBox).not.toBeNull();
expect(addFormAfterBox).not.toBeNull();
expect(finalSourceBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalSourceBox!.y + finalSourceBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(finalSwitchBox!.y).toBeGreaterThanOrEqual(finalSourceBox!.y);
expect(finalSwitchBox!.y + finalSwitchBox!.height).toBeLessThanOrEqual(
finalSourceBox!.y + finalSourceBox!.height,
);
expect(finalRemoveBox!.height).toBeGreaterThanOrEqual(44);
expect(finalRemoveBox!.y).toBeGreaterThanOrEqual(finalSourceBox!.y);
expect(finalRemoveBox!.y + finalRemoveBox!.height).toBeLessThanOrEqual(
finalSourceBox!.y + finalSourceBox!.height,
);
expect(addFormAfterBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(addFormAfterBox!.y + addFormAfterBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
});
@@ -0,0 +1,114 @@
import { expect, test } from "../../fixtures/test-base";
const FINAL_SOURCE_ID = "source-31";
function longMarketplaceCatalog() {
return {
plugins: [],
sources: Array.from({ length: 32 }, (_, index) => ({
id: `source-${index}`,
name: `Registry ${index}`,
url: `https://registry-${index}.example/index.json`,
enabled: true,
builtin: index === 0,
healthy: true,
created_at: "2026-01-01T00:00:00.000Z",
})),
};
}
async function waitForDialog(page: import("@playwright/test").Page) {
await page.goto("/settings/plugins");
await page.getByTestId("plugins-tab-browse").click();
await expect(page.getByTestId("marketplace-manage-sources")).toBeVisible();
await page.getByTestId("marketplace-manage-sources").click();
const dialog = page.getByTestId("marketplace-sources-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
test.describe("Marketplace source dialog", () => {
test("keeps source rows scrolling while title, close, and add form stay reachable", async ({
testPage,
}) => {
test.setTimeout(60_000);
await testPage.route("**/api/plugins/marketplace*", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(longMarketplaceCatalog()),
});
});
const dialog = await waitForDialog(testPage);
const list = dialog.getByTestId("marketplace-sources-list");
const addForm = dialog.getByTestId("marketplace-add-source-form");
const title = dialog.locator('[data-slot="dialog-title"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalSource = list.getByTestId(`marketplace-source-${FINAL_SOURCE_ID}`);
const scrollable = await list.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
const dialogBox = await dialog.boundingBox();
const titleBox = await title.boundingBox();
const closeBox = await close.boundingBox();
const addFormBox = await addForm.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(closeBox).not.toBeNull();
expect(addFormBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(addFormBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(addFormBox!.y + addFormBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
await expect(dialog.getByTestId("marketplace-add-source-url")).toBeVisible();
await expect(dialog.getByTestId("marketplace-add-source-submit")).toBeDisabled();
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await expect(finalSource).toBeVisible();
const finalSourceBox = await finalSource.boundingBox();
const finalRemove = finalSource.getByRole("button", { name: /Remove/i });
const finalRemoveBox = await finalRemove.boundingBox();
const addFormAfterBox = await addForm.boundingBox();
expect(finalSourceBox).not.toBeNull();
expect(finalRemoveBox).not.toBeNull();
expect(addFormAfterBox).not.toBeNull();
expect(finalSourceBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalSourceBox!.y + finalSourceBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(finalRemoveBox!.y).toBeGreaterThanOrEqual(finalSourceBox!.y);
expect(finalRemoveBox!.y + finalRemoveBox!.height).toBeLessThanOrEqual(
finalSourceBox!.y + finalSourceBox!.height,
);
expect(addFormAfterBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(addFormAfterBox!.y + addFormAfterBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
await expect(dialog.getByTestId("marketplace-add-source-url")).toBeVisible();
await expect(dialog.getByTestId("marketplace-add-source-submit")).toBeDisabled();
});
});
@@ -0,0 +1,116 @@
import { expect, test } from "../fixtures/test-base";
import { KanbanPage } from "../pages/kanban-page";
const FINAL_ISSUE_ID = "health-23";
function longHealthResponse() {
return {
healthy: false,
issues: Array.from({ length: 24 }, (_, index) => ({
id: `health-${index}`,
category: "system_resources",
title: `System health issue ${index}`,
message: `The diagnostic message for issue ${index} needs operator attention.`,
severity: index % 2 === 0 ? "warning" : "error",
fix_url: "/settings/system/status",
fix_label: `Fix issue ${index}`,
})),
checks: [],
};
}
test.describe("System health issue dialog", () => {
test("keeps issue cards scrolling while the dialog shell and final Fix stay reachable", async ({
testPage,
backend,
}) => {
test.setTimeout(60_000);
await testPage.setViewportSize({ width: 1280, height: 720 });
await testPage.route(`${backend.baseUrl}/api/v1/system/health`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(longHealthResponse()),
}),
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const issueButton = testPage.getByRole("button", { name: "Setup Issues" });
await expect(issueButton).toBeVisible();
await issueButton.click();
const dialog = testPage.getByTestId("system-health-issues-dialog");
await expect(dialog).toBeVisible();
await dialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") {
return false;
}
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const body = dialog.getByTestId("system-health-issues-body");
const title = dialog.locator('[data-slot="dialog-title"]');
const count = dialog.locator('[data-slot="dialog-description"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalIssue = body.getByTestId(`system-health-issue-${FINAL_ISSUE_ID}`);
const finalFix = finalIssue.getByRole("button", { name: "Fix issue 23" });
await expect(finalIssue).toHaveCount(1);
const scrollable = await body.evaluate((element) => ({
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
expect(scrollable.scrollHeight).toBeGreaterThan(scrollable.clientHeight);
const viewportHeight = await testPage.evaluate(() => window.innerHeight);
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
const dialogBox = await dialog.boundingBox();
const titleBox = await title.boundingBox();
const countBox = await count.boundingBox();
const closeBox = await close.boundingBox();
expect(dialogBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(countBox).not.toBeNull();
expect(closeBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewportHeight);
expect(titleBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(titleBox!.y + titleBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(countBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(countBox!.y + countBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
expect(closeBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(closeBox!.y + closeBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height);
await body.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await finalIssue.scrollIntoViewIfNeeded();
await expect(finalFix).toBeVisible();
const finalIssueBox = await finalIssue.boundingBox();
const finalFixBox = await finalFix.boundingBox();
expect(finalIssueBox).not.toBeNull();
expect(finalFixBox).not.toBeNull();
expect(finalIssueBox!.y).toBeGreaterThanOrEqual(dialogBox!.y);
expect(finalIssueBox!.y + finalIssueBox!.height).toBeLessThanOrEqual(
dialogBox!.y + dialogBox!.height,
);
expect(finalFixBox!.y).toBeGreaterThanOrEqual(finalIssueBox!.y);
expect(finalFixBox!.y + finalFixBox!.height).toBeLessThanOrEqual(
finalIssueBox!.y + finalIssueBox!.height,
);
await finalFix.click();
await expect(dialog).not.toBeVisible();
await expect(testPage).toHaveURL(/\/settings\/system\/status$/);
});
});
+1
View File
@@ -92,6 +92,7 @@
"sortName": "Name",
"sortRecentlyUpdated": "Recently updated",
"sourceNamePlaceholder": "Source name (e.g. Acme Internal)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "Official",
"sourceUnreachable": "Unreachable",
"sourceUnreachableDetail": "<0>{{name}}</0> is unreachable{{detail}}; its plugins are hidden.",
+1
View File
@@ -92,6 +92,7 @@
"sortName": "Ńàḿē",
"sortRecentlyUpdated": "Ŕēćēńţĺŷ ũƥďàţēď",
"sourceNamePlaceholder": "Śōũŕćē ńàḿē (ē.ĝ. Àćḿē Ĩńţēŕńàĺ)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "Ōƒƒĩćĩàĺ",
"sourceUnreachable": "Ũńŕēàćĥàƀĺē",
"sourceUnreachableDetail": "<0>{{name}}</0> ĩś ũńŕēàćĥàƀĺē{{detail}}; ĩţś ƥĺũĝĩńś àŕē ĥĩďďēń.",
+1
View File
@@ -91,6 +91,7 @@
"sortName": "Nome",
"sortRecentlyUpdated": "Atualizados recentemente",
"sourceNamePlaceholder": "Nome da origem (ex.: Acme Interno)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "Oficial",
"sourceUnreachable": "Inacessível",
"sourceUnreachableDetail": "<0>{{name}}</0> está inacessível{{detail}}; os seus plugins estão ocultos.",
+1
View File
@@ -91,6 +91,7 @@
"sortName": "名称",
"sortRecentlyUpdated": "最近更新",
"sourceNamePlaceholder": "来源名称(例如 Acme Internal)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "官方",
"sourceUnreachable": "无法访问",
"sourceUnreachableDetail": "<0>{{name}}</0> 无法访问{{detail}};其插件已隐藏。",
+1
View File
@@ -91,6 +91,7 @@
"sortName": "名稱",
"sortRecentlyUpdated": "最近更新",
"sourceNamePlaceholder": "來源名稱(例如 Acme Internal)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "官方",
"sourceUnreachable": "無法存取",
"sourceUnreachableDetail": "<0>{{name}}</0> 無法存取{{detail}};其外掛程式已隱藏。",
+1
View File
@@ -91,6 +91,7 @@
"sortName": "名稱",
"sortRecentlyUpdated": "最近更新",
"sourceNamePlaceholder": "來源名稱(例如 Acme Internal)",
"sourceUrlPlaceholder": "https://.../index.json",
"sourceOfficial": "官方",
"sourceUnreachable": "無法存取",
"sourceUnreachableDetail": "<0>{{name}}</0> 無法存取{{detail}};其外掛已隱藏。",
@@ -0,0 +1,144 @@
---
spec: docs/specs/ui/requirements/dialog-content-containment.md
design: docs/specs/ui/system-design/dialog-content-containment.md
created: 2026-08-26
status: complete
---
# Implementation Plan: Contain Growing Dialog Content
## Overview
Five dialogs let repeated or plugin-provided content determine the outer modal
height. Long content can therefore grow beyond the viewport and make close,
completion, or item actions difficult or impossible to reach. Apply the same
bounded-shell pattern locally to each surface, with one designated scroll body
and surface-specific persistent controls.
## Root cause
The affected Dialog and AlertDialog contents have no dynamic-viewport cap and
no child with both a bounded row and `min-height: 0`. Their repeated content is
rendered in normal content-sized flow, so the outer modal expands instead of
creating an internal scroll range. The shared primitives behave as designed;
the missing constraint belongs to each composition.
The plugin drawer is not affected because it already combines a `dvh` cap with
a `min-h-0 overflow-y-auto` content body.
## Scope
- Contain the agent-profile deletion conflict list and preserve its footer.
- Contain Marketplace source rows and preserve the add-source form.
- Contain System Health issue cards and keep every Fix action reachable.
- Contain opaque plugin dialog content while preserving dismissibility rules.
- Contain the Office Create Project form and preserve its action footer.
- Add focused desktop and phone browser geometry regressions for every surface.
## Exclusions
- No backend, API, persisted model, store, or plugin SDK changes.
- No remediation buttons or changed conflict semantics in profile deletion.
- No shared Dialog or AlertDialog primitive behavior change.
- No plugin drawer change and no forced change to plugin presentation choice.
- No user-facing copy changes and no audit expansion beyond these five dialogs.
## Technical approach
For each affected content surface:
1. Add a dynamic-viewport maximum height and outer overflow containment.
2. Use explicit auto/minmax/auto rows, or the equivalent flex composition.
3. Give the growing body `min-h-0`, vertical auto overflow, overscroll
containment, and a stable browser-test selector.
4. Keep the local header and designated persistent controls outside the body.
5. Preserve short-content intrinsic height and existing action semantics.
Do not introduce a speculative shared wrapper. The five components use
different primitives and different persistent-row semantics; the established
utility-class composition is small and clearer when applied locally.
## Mobile design contract
- Centered dialog presentation remains unchanged; plugin drawer presentation
remains the existing bottom drawer.
- Every affected dialog fits within the dynamic phone viewport and has exactly
one new vertical scroll owner.
- Required phone actions are at least 44 pixels high and remain reachable.
- Long labels, URLs, repository chips, and plugin content do not create
document horizontal overflow.
- Phone and desktop retain equivalent content, actions, and outcomes.
## Testing strategy
Follow RED-GREEN-REFACTOR separately for each work order. The RED case must use
enough real or intercepted data to prove the current modal exceeds the
viewport. The GREEN case asserts the outer dialog bounds, body scroll range,
final-item reachability, persistent controls, and phone overflow/touch rules.
Component tests remain focused on callback and conditional-rendering behavior.
They may assert stable layout seams, but jsdom is not accepted as geometry
evidence.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web test -- components/settings/agent-profile-delete-dialog.test.tsx components/settings/plugins/marketplace-sources-dialog.test.tsx components/system-health/health-indicator.test.tsx components/plugins/plugin-modal-host.test.tsx)
(cd apps/web && pnpm e2e:run --host -- tests/settings/agent-profile-delete.spec.ts tests/settings/plugin-marketplace-sources.spec.ts tests/system-health-dialog.spec.ts tests/plugins/plugins.spec.ts tests/office/project-repository-picker.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/settings/mobile-agent-profile-delete.spec.ts tests/settings/mobile-plugin-marketplace-sources.spec.ts tests/mobile-system-health-dialog.spec.ts tests/plugins/mobile-plugin-modal.spec.ts tests/office/mobile-project-create-dialog.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/settings/agent-profile-delete-dialog.tsx components/settings/plugins/marketplace-sources-dialog.tsx components/system-health/health-indicator.tsx components/plugins/plugin-modal-host.tsx app/office/projects/create-project-dialog.tsx e2e/tests/settings/agent-profile-delete.spec.ts e2e/tests/settings/mobile-agent-profile-delete.spec.ts e2e/tests/settings/plugin-marketplace-sources.spec.ts e2e/tests/settings/mobile-plugin-marketplace-sources.spec.ts e2e/tests/system-health-dialog.spec.ts e2e/tests/mobile-system-health-dialog.spec.ts e2e/tests/plugins/plugins.spec.ts e2e/tests/plugins/mobile-plugin-modal.spec.ts e2e/tests/office/project-repository-picker.spec.ts e2e/tests/office/mobile-project-create-dialog.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Implementation waves
One sequential wave. The work orders are logically independent and have no
data dependencies, but each owns its own RED-GREEN evidence and should be
completed before the next surface starts.
- [x] [Task 01: Contain agent conflict details](task-01-agent-profile-conflict.md)
- [x] [Task 02: Contain Marketplace sources](task-02-marketplace-sources.md)
- [x] [Task 03: Contain health issue cards](task-03-health-issues.md)
- [x] [Task 04: Contain plugin dialog content](task-04-plugin-dialog.md)
- [x] [Task 05: Contain Create Project form](task-05-create-project.md)
## Risks and boundaries
- `minmax(0, 1fr)` and `min-h-0` are both required; omitting either can restore
content-sized expansion.
- Marketplace and Office need their add/completion rows outside the scroll body;
moving them into it would technically contain the dialog but fail the
reachability outcome.
- Plugin content is opaque. The host can guarantee reachability through scroll,
but cannot infer or extract a plugin-owned footer.
- Health Fix actions remain in the scroll body because they are item-local.
- Test fixtures must preserve actual API and plugin-host shapes so the browser
regressions exercise production composition paths.
## Public documentation
No public documentation change. This restores reachability of existing UI and
does not add or rename a user-facing capability.
## Open questions
None.
## Results
- All five dialogs use local dynamic-viewport containment with one designated
vertical scroll body. The shared Dialog and AlertDialog primitives, APIs,
business actions, and plugin drawer remain unchanged.
- Agent conflict, Marketplace Sources, System Health, packaged plugin modal,
and Office Create Project cases each have focused desktop Chromium and
mobile-Chrome geometry coverage. The Office shell uses `overflow: clip` so
the existing inline repository picker cannot move the dialog horizontally.
- The RED/GREEN work-order evidence is recorded in the five task files.
- Final verification passed: 24 focused component tests, 29 focused desktop
Chromium tests, 7 focused mobile-Chrome tests, web typecheck, targeted ESLint
with no errors or warnings, i18n check and ratchet, specification lint, and
`git diff --check`.
@@ -0,0 +1,102 @@
---
id: "01-agent-profile-conflict"
title: "Contain agent conflict details"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/dialog-content-containment.md"
design: "../../specs/ui/system-design/dialog-content-containment.md"
---
# Task 01: Contain Agent Conflict Details
## Outcome
Long agent-profile conflict lists scroll inside the dialog while the title and
permitted deletion decisions remain visible on desktop and phone.
## In scope
- Add desktop and mobile-Chrome RED cases using the structured profile DELETE
409 response with enough task references to exceed the viewport.
- Convert `AgentProfileDeleteConflictDialog` to the bounded three-row layout.
- Add stable dialog and conflict-body selectors.
- Keep phone footer actions full-width and at least 44 pixels high.
## Exclusions
- Do not change conflict parsing, grouping, force-delete rules, or requests.
- Do not add archive, delete, disable, reassignment, or bulk remediation.
- Do not change the simple confirmation or shared AlertDialog primitive.
## Applicable requirements
- `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001`
- `AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1` through
`AC-UI-DIALOG-CONTENT-CONTAINMENT-001.9`
## Implementation acceptance
1. The long conflict response creates one body scroll range while the dialog,
title, and footer stay inside desktop and phone viewports.
2. The final conflict is reachable; Cancel/Delete Anyway stay visible and
hit-testable when allowed; hard blockers still show Cancel only.
3. Short content, action ordering, dismissal, confirmation, and requests remain
unchanged.
## TDD sequence
1. Add stable selectors and long-conflict desktop/phone browser cases.
2. RED: record the current outer overflow or unreachable footer geometry.
3. GREEN: apply the dynamic cap, auto/minmax/auto grid, body overflow, and phone
action sizing.
4. Run focused component, E2E, type, lint, i18n, and diff checks.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- components/settings/agent-profile-delete-dialog.test.tsx)
(cd apps/web && pnpm e2e:run --host -- tests/settings/agent-profile-delete.spec.ts --grep "keeps conflict actions visible with many tasks")
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/settings/mobile-agent-profile-delete.spec.ts --grep "keeps conflict actions visible with many tasks")
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/settings/agent-profile-delete-dialog.tsx components/settings/agent-profile-delete-dialog.test.tsx e2e/tests/settings/agent-profile-delete.spec.ts e2e/tests/settings/mobile-agent-profile-delete.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
git diff --check
```
## Files likely touched
- `apps/web/components/settings/agent-profile-delete-dialog.tsx`
- `apps/web/components/settings/agent-profile-delete-dialog.test.tsx`
- `apps/web/e2e/tests/settings/agent-profile-delete.spec.ts`
- `apps/web/e2e/tests/settings/mobile-agent-profile-delete.spec.ts`
- `docs/plans/dialog-content-containment/plan.md`
- `docs/plans/dialog-content-containment/task-01-agent-profile-conflict.md`
## Dependencies
None.
## Parallelism
`sequential`. This work order owns one responsive RED-GREEN cycle.
## Output contract
Record RED/GREEN geometry, focused results, files changed, cleanup, blockers,
and residual risks. Reconcile this work order and the plan with the final diff.
## Results
- RED: The new long structured 409 conflict cases exposed the unbounded dialog
and missing stable body/footer seams on desktop and mobile.
- GREEN: The conflict details now use a capped AlertDialog with an
auto/minmax/auto grid and one scrolling description body. Cancel and the
permitted Delete Anyway action remain in the footer, while routing-tier hard
blockers still omit Delete Anyway.
- Focused component coverage and the desktop/mobile conflict geometry cases
passed. The final combined run included the existing deletion scenarios and
the long-task case.
- No conflict parsing, force-delete request, action ordering, or dismissal
behavior changed.
@@ -0,0 +1,103 @@
---
id: "02-marketplace-sources"
title: "Contain Marketplace sources"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/dialog-content-containment.md"
design: "../../specs/ui/system-design/dialog-content-containment.md"
---
# Task 02: Contain Marketplace Sources
## Outcome
A long Marketplace source list scrolls inside its dialog while the title,
close control, and add-source form remain visible on desktop and phone.
## In scope
- Add focused component coverage for local row composition and unchanged source
actions.
- Add desktop and mobile-Chrome browser regressions by intercepting the existing
marketplace catalog response with many real `MarketplaceSource` shapes.
- Make the source list the scroll body and keep `AddSourceForm` in the final
persistent row.
- Add stable dialog and source-list selectors; keep source row controls
reachable and touch-safe.
## Exclusions
- Do not change marketplace APIs, reload behavior, validation, or source order.
- Do not change add, toggle, or remove semantics.
- Do not change the shared Dialog primitive.
## Applicable requirements
- `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001`
- `AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1` through
`AC-UI-DIALOG-CONTENT-CONTAINMENT-001.8`
## Implementation acceptance
1. Many sources create one source-list scroll range while the dialog, header,
close control, and add form remain within both viewports.
2. The final source and its controls are reachable; the add form remains usable
before and after scrolling.
3. Short lists, mutation callbacks, source order, and dismissal remain
unchanged, with no phone document overflow.
## TDD sequence
1. Add the long catalog fixtures, stable selectors, and desktop/phone cases.
2. RED: capture the current oversized dialog and displaced add form.
3. GREEN: apply the capped three-row composition and list overflow boundary.
4. Run focused component, E2E, type, lint, i18n, and diff checks.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- components/settings/plugins/marketplace-sources-dialog.test.tsx)
(cd apps/web && pnpm e2e:run --host -- tests/settings/plugin-marketplace-sources.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/settings/mobile-plugin-marketplace-sources.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/settings/plugins/marketplace-sources-dialog.tsx components/settings/plugins/marketplace-sources-dialog.test.tsx e2e/tests/settings/plugin-marketplace-sources.spec.ts e2e/tests/settings/mobile-plugin-marketplace-sources.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
git diff --check
```
## Files likely touched
- `apps/web/components/settings/plugins/marketplace-sources-dialog.tsx`
- `apps/web/components/settings/plugins/marketplace-sources-dialog.test.tsx`
- `apps/web/e2e/tests/settings/plugin-marketplace-sources.spec.ts`
- `apps/web/e2e/tests/settings/mobile-plugin-marketplace-sources.spec.ts`
- `docs/plans/dialog-content-containment/plan.md`
- `docs/plans/dialog-content-containment/task-02-marketplace-sources.md`
## Dependencies
None.
## Parallelism
`sequential`. This work order owns one responsive RED-GREEN cycle.
## Output contract
Record RED/GREEN geometry, source and add-form reachability, focused results,
files changed, cleanup, blockers, and residual risks.
## Results
- RED: The long catalog cases showed the source list growing the dialog beyond
the available viewport before the local containment boundary existed.
- GREEN: Source rows now occupy one capped scrolling body. The title, close
control, and Add Source form remain outside that body, and source toggles and
remove actions keep their existing callbacks with phone-sized controls.
- The focused component test, desktop Chromium case, and mobile-Chrome case
passed, including final-source reachability and add-form availability after
scrolling.
- Marketplace loading, mutation, ordering, validation, and dismissal behavior
remain unchanged.
@@ -0,0 +1,101 @@
---
id: "03-health-issues"
title: "Contain health issue cards"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/dialog-content-containment.md"
design: "../../specs/ui/system-design/dialog-content-containment.md"
---
# Task 03: Contain Health Issue Cards
## Outcome
Many System Health issues scroll inside their dialog, and every issue Fix action
remains reachable and touch-safe on desktop and phone.
## In scope
- Add focused component coverage for rendering and unchanged navigation
callbacks.
- Add desktop and mobile-Chrome browser regressions by intercepting the system
health response with many actual `HealthIssue` shapes.
- Make the issue-card list the one scroll body and add stable layout selectors.
- Give phone Fix actions a 44-pixel minimum height while retaining compact
desktop density.
## Exclusions
- Do not change health polling, issue models, labels, ordering, or fix URLs.
- Do not add a persistent footer because Fix actions are item-local.
- Do not change the shared Dialog primitive.
## Applicable requirements
- `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001`
- `AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1`, `.2`, `.4` through `.9`
## Implementation acceptance
1. Many issue cards create one body scroll range while the outer dialog, title,
count, and close control remain inside both viewports.
2. The final issue and its Fix action are reachable; tapping Fix keeps the
existing close-then-navigate behavior.
3. Short issue lists retain compact height, phone Fix actions meet 44 pixels,
and the document has no horizontal overflow.
## TDD sequence
1. Add long health fixtures, stable selectors, and desktop/phone cases.
2. RED: record the current oversized dialog and unreachable final Fix action.
3. GREEN: apply the bounded header/body composition and responsive action
sizing.
4. Run focused component, E2E, type, lint, i18n, and diff checks.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- components/system-health/health-indicator.test.tsx)
(cd apps/web && pnpm e2e:run --host -- tests/system-health-dialog.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/mobile-system-health-dialog.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/system-health/health-indicator.tsx components/system-health/health-indicator.test.tsx e2e/tests/system-health-dialog.spec.ts e2e/tests/mobile-system-health-dialog.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
git diff --check
```
## Files likely touched
- `apps/web/components/system-health/health-indicator.tsx`
- `apps/web/components/system-health/health-indicator.test.tsx`
- `apps/web/e2e/tests/system-health-dialog.spec.ts`
- `apps/web/e2e/tests/mobile-system-health-dialog.spec.ts`
- `docs/plans/dialog-content-containment/plan.md`
- `docs/plans/dialog-content-containment/task-03-health-issues.md`
## Dependencies
None.
## Parallelism
`sequential`. This work order owns one responsive RED-GREEN cycle.
## Output contract
Record RED/GREEN geometry, final Fix reachability, routing evidence, focused
results, files changed, cleanup, blockers, and residual risks.
## Results
- RED: The long health response made the issue dialog content-sized, leaving
the final Fix action outside the viewport.
- GREEN: Issue cards now occupy one capped scrolling body. Each Fix action
remains inside its card, receives a 44-pixel minimum phone height, and keeps
the existing close-then-navigate behavior.
- The focused component test and desktop/mobile geometry cases passed,
including final Fix reachability, hit testing, URL navigation, and phone
document-overflow checks.
- Health polling, issue data, ordering, labels, and fix URLs remain unchanged.
@@ -0,0 +1,109 @@
---
id: "04-plugin-dialog"
title: "Contain plugin dialog content"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/dialog-content-containment.md"
design: "../../specs/ui/system-design/dialog-content-containment.md"
---
# Task 04: Contain Plugin Dialog Content
## Outcome
Opaque plugin content scrolls inside host dialog presentation while the
host-owned title and permitted close control remain visible on desktop and
phone.
## In scope
- Extend `PluginModalHost` component tests for dialog-body composition and
unchanged dismissibility behavior.
- Extend the packaged E2E fixture with a long-content modal path that exercises
the real `host.openModal` contract.
- Add desktop and mobile-Chrome geometry regressions for the dialog
presentation.
- Put `PluginErrorBoundary` and plugin content inside one bounded scroll body;
add stable host layout selectors.
## Exclusions
- Do not change `PluginModalOptions`, modal-manager behavior, or plugin APIs.
- Do not infer or extract a plugin-owned footer.
- Do not change `PluginDrawer`, presentation selection, or nondismissible
Escape/outside-interaction guards.
## Applicable requirements
- `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001`
- `AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1`, `.2`, `.4` through `.9`
## Implementation acceptance
1. Long fixture content creates one plugin-dialog body scroll range while the
outer dialog, host header, and dismissible close control stay in both
viewports.
2. The final plugin-owned control is reachable and operable after scrolling.
3. Dismissible and nondismissible behavior, error isolation, short content, and
drawer presentation remain unchanged; phone has no document overflow.
## TDD sequence
1. Add long plugin fixture content, stable selectors, and desktop/phone cases.
2. RED: record current outer overflow and unreachable final plugin control.
3. GREEN: cap `PluginDialog` and wrap the error boundary/content in the body.
4. Run fixture packaging as required, focused component/E2E, type, lint, i18n,
and diff checks.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- components/plugins/plugin-modal-host.test.tsx)
(cd apps/web && pnpm e2e:run --host -- tests/plugins/plugins.spec.ts --grep "long plugin modal")
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/plugins/mobile-plugin-modal.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/plugins/plugin-modal-host.tsx components/plugins/plugin-modal-host.test.tsx e2e/tests/plugins/plugins.spec.ts e2e/tests/plugins/mobile-plugin-modal.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
git diff --check
```
## Files likely touched
- `apps/web/components/plugins/plugin-modal-host.tsx`
- `apps/web/components/plugins/plugin-modal-host.test.tsx`
- `apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js`
- `apps/web/e2e/tests/plugins/plugins.spec.ts`
- `apps/web/e2e/tests/plugins/mobile-plugin-modal.spec.ts`
- `docs/plans/dialog-content-containment/plan.md`
- `docs/plans/dialog-content-containment/task-04-plugin-dialog.md`
## Dependencies
None.
## Parallelism
`sequential`. This work order owns one responsive RED-GREEN cycle and the
shared packaged plugin fixture.
## Output contract
Record RED/GREEN geometry, final plugin-control reachability, dismissibility
and drawer checks, focused results, files changed, cleanup, and residual risks.
## Results
- RED: Component coverage initially found no host dialog/body layout seams, and
the packaged long-modal browser case found no bounded body for the opaque
plugin content.
- GREEN: `PluginDialog` now has a dynamic cap and one scroll body containing the
existing error boundary and plugin content. The host-owned title and
dismissible close control remain outside it; nondismissible guards and the
existing drawer are unchanged.
- The plugin host component suite, packaged fixture desktop Chromium case, and
mobile-Chrome case passed. The final plugin action remains operable after
scrolling and the modal can be dismissed when allowed.
- The fixture change is limited to long modal content and its completion
control; no plugin API or SDK contract changed.
@@ -0,0 +1,103 @@
---
id: "05-create-project"
title: "Contain Create Project form"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/dialog-content-containment.md"
design: "../../specs/ui/system-design/dialog-content-containment.md"
---
# Task 05: Contain Create Project Form
## Outcome
The Office Create Project form scrolls when repository chips or form content
grow, while Cancel/Create Project remain visible on desktop and phone.
## In scope
- Add desktop and mobile-Chrome browser regressions that add enough repository
chips through the existing picker to exceed the dialog viewport.
- Make `ProjectFormBody` the bounded scroll body and keep the existing footer in
the final persistent row.
- Add stable dialog/body/footer selectors and touch-safe phone actions.
- Preserve repository picker wiring and create payload behavior.
## Exclusions
- Do not change Office project APIs, form fields, defaults, validation, or
repository semantics.
- Do not change the project detail repository picker.
- Do not change the shared Dialog primitive.
## Applicable requirements
- `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001`
- `AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1` through
`AC-UI-DIALOG-CONTENT-CONTAINMENT-001.8`
## Implementation acceptance
1. Many repository chips create one form-body scroll range while the dialog,
title, and action footer remain inside both viewports.
2. The final chip and its remove action are reachable, and Cancel/Create Project
stay visible and hit-testable throughout scrolling.
3. Creating a project still persists chosen repositories; short content stays
compact and phone has no horizontal document overflow.
## TDD sequence
1. Add stable selectors and long-repository desktop/phone browser cases.
2. RED: record the current oversized dialog or displaced footer.
3. GREEN: apply the capped header/body/footer composition and phone action
sizing.
4. Run focused E2E, type, lint, i18n, and diff checks.
## Verification
```bash
(cd apps/web && pnpm e2e:run --host -- tests/office/project-repository-picker.spec.ts --grep "create-project dialog")
(cd apps/web && pnpm e2e:run --host --project mobile-chrome -- tests/office/mobile-project-create-dialog.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint app/office/projects/create-project-dialog.tsx e2e/tests/office/project-repository-picker.spec.ts e2e/tests/office/mobile-project-create-dialog.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
git diff --check
```
## Files likely touched
- `apps/web/app/office/projects/create-project-dialog.tsx`
- `apps/web/e2e/tests/office/project-repository-picker.spec.ts`
- `apps/web/e2e/tests/office/mobile-project-create-dialog.spec.ts`
- `docs/plans/dialog-content-containment/plan.md`
- `docs/plans/dialog-content-containment/task-05-create-project.md`
## Dependencies
None.
## Parallelism
`sequential`. This work order owns one responsive RED-GREEN cycle.
## Output contract
Record RED/GREEN geometry, chip and footer reachability, create-flow evidence,
focused results, files changed, cleanup, blockers, and residual risks.
## Results
- RED: The new long-chip desktop and mobile cases initially failed because the
Create Project dialog had no stable bounded shell/body seam.
- GREEN: `ProjectFormBody` now occupies one dynamic capped scroll body while
Cancel and Create Project remain in a persistent footer. Phone actions are
stacked at a 44-pixel minimum. The shell uses `overflow: clip` so the
existing inline picker cannot horizontally scroll the dialog; the picker
wiring and project payload remain unchanged.
- The desktop Chromium and mobile-Chrome long-chip cases passed. They prove
body scroll range, final chip/remove reachability, persistent footer geometry,
footer hit testing, cancel behavior, and phone document-overflow safety.
- A parallel Office rerun hit the E2E harness shared backend port; the affected
mobile case passed on the required sequential rerun.
+2
View File
@@ -42,6 +42,7 @@ contract that other capabilities can reuse.
- [Adaptive Kanban](requirements/adaptive-kanban.md)
- [Task Add-Panel PR Submenu](requirements/add-panel-pr-submenu.md)
- [Agent Launch Prompt Composer](requirements/agent-launch-prompt-composer.md)
- [Growing dialog content containment](requirements/dialog-content-containment.md)
- [Agent-message inline comments](requirements/agent-message-comments.md)
- [Agent Todo List Panel](requirements/agent-todo-list-panel.md)
- [App Status Bar](requirements/app-status-bar.md)
@@ -143,6 +144,7 @@ contract that other capabilities can reuse.
### System design
- [Growing Dialog Content Containment](system-design/dialog-content-containment.md)
- [Agent Todo List Panel](system-design/agent-todo-list-panel.md)
- [App Status Bar](system-design/app-status-bar.md)
- [Changes File Row Containment](system-design/changes-file-row-containment.md)
@@ -0,0 +1,86 @@
---
status: active
system: ui
created: 2026-08-26
owners:
- kandev
---
# Growing Dialog Content Containment Requirements
## Overview
Dialogs whose content grows from user or runtime data must remain usable when
that content is taller than the current browser viewport. The UI system owns
the reusable viewport-containment contract. Each consuming system continues to
own its data, actions, validation, and dismissal semantics.
The current consumers are the agent-profile deletion conflict dialog,
Marketplace Sources, System Health Issues, plugin-hosted dialog presentation,
and the Office Create Project dialog.
## Terminology
- **Growing content:** Repeated or plugin-provided content whose rendered height
is not fixed, such as conflict rows, sources, health issues, plugin content,
or repository chips.
- **Scroll body:** The single region inside a covered dialog that absorbs
vertical growth.
- **Persistent controls:** Controls intentionally placed outside the scroll
body because they must remain reachable, such as confirmation actions, an
add form, or the dialog close control.
## Requirements
### REQ-UI-DIALOG-CONTENT-CONTAINMENT-001: Reachable dialog content and controls
**Intent:** Keep growing dialog content and required actions reachable at every
supported viewport size.
**User story:** As a user working in a dialog with many items, I want to scroll
the items without losing the dialog controls, so that I can review the content
and complete or cancel the operation.
#### Acceptance criteria
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.1:** When growing content exceeds the
available dynamic viewport height, the system shall contain the outer dialog
within that viewport and make exactly one designated body vertically
scrollable.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.2:** While the body scrolls, the system
shall keep the dialog title and any host-owned close control visible.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.3:** The system shall keep controls
designated as persistent outside the scroll body and reachable without
scrolling that body. This includes the agent conflict decision footer, the
Marketplace add-source form, and the Office Create Project footer.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.4:** Every item and item-level action
inside the body shall remain reachable by scrolling, with its existing
content order and business behavior unchanged.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.5:** When growing content fits, the
system shall retain a compact intrinsic dialog height without an unnecessary
internal scrollbar.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.6:** When growing content changes while
a dialog is open, the designated body shall absorb the new height without
moving persistent controls outside the viewport.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.7:** On a phone viewport, each covered
dialog shall remain within the dynamic viewport, shall not create document
horizontal overflow, and shall expose required actions with touch targets at
least 44 pixels high.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.8:** Each covered dialog shall retain
its existing focus management, Escape behavior, outside-interaction behavior,
action ordering, and semantic confirmation behavior.
- **AC-UI-DIALOG-CONTENT-CONTAINMENT-001.9:** Surface-specific safety rules shall
remain unchanged: agent hard blockers omit Delete Anyway, health Fix actions
navigate to their current targets, plugin dismissibility remains host
enforced, and plugin drawer presentation remains unchanged.
## Out of scope
- Changing backend payloads, persisted models, validation, or business actions.
- Adding per-item or bulk remediation actions to the agent deletion conflict.
- Changing plugin APIs or a plugin's selected dialog versus drawer
presentation.
- Changing conflict, marketplace, health, plugin, or Office content and order.
- Applying a global behavioral change to the shared Dialog or AlertDialog
primitives.
- Retrofitting dialogs outside the five audited consumers in this package.
@@ -0,0 +1,161 @@
---
status: current
system: ui
requirements:
- REQ-UI-DIALOG-CONTENT-CONTAINMENT-001
---
# Growing Dialog Content Containment System Design
## Purpose and boundaries
This design applies one viewport-containment pattern to five dialogs whose
body height grows from user or runtime data. The UI system owns the bounded
shell, scroll ownership, and responsive geometry. Agent settings, marketplace,
system health, plugins, and Office retain ownership of their data and actions.
The design changes local dialog compositions. It does not change the shared
Dialog or AlertDialog primitive and does not introduce a new public component
API.
## Requirement mapping
| Requirement | Design sections |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-DIALOG-CONTENT-CONTAINMENT-001` | [Composition contract](#composition-contract), [Surface compositions](#surface-compositions), [Responsive interaction](#responsive-interaction) |
## Composition contract
Each covered dialog uses a dynamic-viewport height cap and hides overflow at
the outer content surface. Its layout has explicit content-sized rows around a
`minmax(0, 1fr)` body row, or the equivalent flex composition. The body has
`min-height: 0`, vertical auto overflow, overscroll containment, and horizontal
overflow containment.
The body is the only region newly responsible for vertical scrolling. Headers,
host close controls, and surface-specific persistent controls stay outside it.
The height cap is a maximum rather than a fixed height, so short content keeps
the current compact presentation.
Stable dialog and body selectors expose these layout boundaries to Playwright.
They are test seams, not product APIs.
## Surface compositions
### Agent-profile deletion conflict
`AgentProfileDeleteConflictDialog` uses an auto/minmax/auto AlertDialog grid.
The title remains in the header, all conflict groups and consequence text move
into the scroll body, and Cancel/Delete Anyway remain in the footer. Routing
tier hard blockers continue to omit Delete Anyway. The conflict body retains
the AlertDialog description relationship.
### Marketplace Sources
`MarketplaceSourcesDialog` keeps its title and description in the header. The
source-item list becomes the scroll body. `AddSourceForm` remains outside that
body in the final auto-sized row, so source name, URL, and Add remain available
while the source list scrolls. Toggle and remove controls stay with their source
items and are reachable through the body.
### System Health Issues
`HealthIssuesDialog` keeps its title and count description in the header. The
issue-card list becomes the scroll body. There is no persistent footer; each
Fix action remains inside its issue card and becomes reachable by scrolling.
Phone Fix actions gain a 44-pixel minimum touch height while desktop can retain
compact sizing.
### Plugin Modal Host
Only `PluginDialog` receives the bounded composition. Its optional host-owned
title and description remain in the header, and `PluginErrorBoundary` plus the
plugin content component move inside the scroll body. Because plugin content is
opaque, plugin-owned buttons scroll with that content; the host guarantees that
all of it can be reached. The host close control remains visible when the modal
is dismissible. Nondismissible outside-click and Escape guards remain intact.
`PluginDrawer` already caps itself at the dynamic viewport and gives plugin
content a bounded scroll region, so its composition remains unchanged.
### Office Create Project
`CreateProjectDialog` keeps the title in the header, places `ProjectFormBody`
in the scroll body, and keeps Cancel/Create Project in the final auto-sized row.
Repository chips remain part of the form body and can grow or wrap without
pushing the footer outside the viewport.
## Components and responsibilities
- Each dialog component owns its local row composition and stable layout test
selectors.
- Existing child components continue to own source mutations, health routing,
plugin rendering, profile deletion callbacks, and Office form state.
- `@kandev/ui/dialog` and `@kandev/ui/alert-dialog` continue to own focus traps,
portals, accessible names, Escape handling, and outside-interaction behavior.
- Browser tests own geometry assertions. Component tests cover conditional
actions and callback semantics but do not substitute jsdom for layout proof.
## Data and contracts
No API, persisted state, store, event, or plugin SDK changes. Each surface uses
its existing data shape and callback contract. The only new contracts are local
DOM layout boundaries used for browser verification.
## Responsive interaction
- Centered dialog presentation remains unchanged for the four app-owned
dialogs and for plugin content that requests dialog presentation.
- Dynamic viewport units account for browser chrome changes on phones.
- Narrow layouts wrap or stack persistent actions without creating document
horizontal overflow.
- Required actions meet the 44-pixel phone touch minimum; desktop retains its
existing compact density.
- Desktop and phone expose the same data, item actions, dismissal rules, and
completion outcomes.
## Control flow
1. Existing data or plugin content opens a covered dialog.
2. The dialog renders its header, designated body, and any persistent controls.
3. If content exceeds the height cap, only the body becomes scrollable.
4. Scrolling changes the body's scroll offset without moving the outer dialog
or persistent rows.
5. Existing item actions, completion actions, and dismissal callbacks execute
unchanged.
## Failure and recovery
- A shorter viewport or newly added item shrinks or extends the body scroll
range while persistent rows remain visible.
- Omitting either `minmax(0, 1fr)` or `min-height: 0` can restore content-sized
growth and move controls beyond the viewport. Desktop and phone geometry
regressions guard both constraints.
- Plugin render failures continue through `PluginErrorBoundary` inside the
bounded body.
- Closing and reopening continues to reset transient scroll state through the
existing Radix mount lifecycle.
## Persistence
None. Open state and scroll position remain transient.
## Security
No trust boundary changes. Plugin content remains isolated by the existing
error boundary, and all destructive or navigational actions keep their current
authorization and request paths.
## Observability and verification
No production telemetry is added. Focused Chromium and mobile-Chrome browser
tests exercise long content on every surface and assert outer-dialog viewport
containment, a body with real scroll range, reachable final content, visible
persistent controls where applicable, touch-safe required actions, and no
document horizontal overflow. Existing focused component tests continue to
cover callbacks, dismissibility, and conditional action rules.
## Related decisions
None. This applies an established local Dialog composition and does not create
a new architectural boundary.