fix(ui): contain growing dialog content (#3060)
This commit is contained in:
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"sortName": "Ńàḿē",
|
||||
"sortRecentlyUpdated": "Ŕēćēńţĺŷ ũƥďàţēď",
|
||||
"sourceNamePlaceholder": "Śōũŕćē ńàḿē (ē.ĝ. Àćḿē Ĩńţēŕńàĺ)",
|
||||
"sourceUrlPlaceholder": "https://.../index.json",
|
||||
"sourceOfficial": "Ōƒƒĩćĩàĺ",
|
||||
"sourceUnreachable": "Ũńŕēàćĥàƀĺē",
|
||||
"sourceUnreachableDetail": "<0>{{name}}</0> ĩś ũńŕēàćĥàƀĺē{{detail}}; ĩţś ƥĺũĝĩńś àŕē ĥĩďďēń.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"sortName": "名称",
|
||||
"sortRecentlyUpdated": "最近更新",
|
||||
"sourceNamePlaceholder": "来源名称(例如 Acme Internal)",
|
||||
"sourceUrlPlaceholder": "https://.../index.json",
|
||||
"sourceOfficial": "官方",
|
||||
"sourceUnreachable": "无法访问",
|
||||
"sourceUnreachableDetail": "<0>{{name}}</0> 无法访问{{detail}};其插件已隐藏。",
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"sortName": "名稱",
|
||||
"sortRecentlyUpdated": "最近更新",
|
||||
"sourceNamePlaceholder": "來源名稱(例如 Acme Internal)",
|
||||
"sourceUrlPlaceholder": "https://.../index.json",
|
||||
"sourceOfficial": "官方",
|
||||
"sourceUnreachable": "無法存取",
|
||||
"sourceUnreachableDetail": "<0>{{name}}</0> 無法存取{{detail}};其外掛程式已隱藏。",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user