Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions frontend/app/e2e/suggestPanelLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* The suggest notice has three deliberate layouts: expanded, reduced while a
* proposal needs a decision, and minimal while it has no live controls. These
* browser assertions keep each visible control inside its own notice through a
* reduce/restore cycle.
*/

import { expect, test, type Locator, type Page, type Request } from "@playwright/test";

import { openJob } from "./_wireApiStub";

async function arm(page: Page): Promise<void> {
const sent: Request[] = [];
await openJob(page, sent, undefined, undefined, undefined, undefined, true);
await page.getByTestId("tool-suggest").click();
await expect(page.getByTestId("suggest-idle")).toBeVisible();
}

async function showProposal(page: Page): Promise<void> {
await arm(page);
const canvas = await page.getByTestId("annotator-canvas").boundingBox();
if (canvas === null) throw new Error("The annotation canvas is not visible");
await page.mouse.click(canvas.x + canvas.width / 2, canvas.y + canvas.height / 2);
await expect(page.getByTestId("suggestion-shape")).toBeVisible();
}

async function expectContainedBy(notice: Locator, control: Locator): Promise<void> {
const [noticeBox, controlBox] = await Promise.all([notice.boundingBox(), control.boundingBox()]);
expect(noticeBox).not.toBeNull();
expect(controlBox).not.toBeNull();
if (noticeBox === null || controlBox === null) return;

expect(controlBox.x).toBeGreaterThanOrEqual(noticeBox.x);
expect(controlBox.y).toBeGreaterThanOrEqual(noticeBox.y);
expect(controlBox.x + controlBox.width).toBeLessThanOrEqual(noticeBox.x + noticeBox.width);
expect(controlBox.y + controlBox.height).toBeLessThanOrEqual(noticeBox.y + noticeBox.height);
}

test("a minimal suggest notice contains its reopen control after reducing and restoring", async ({ page }) => {
await arm(page);
const notice = page.getByTestId("suggest-panel");
const icon = page.getByTestId("suggest-panel-icon");
const toggle = page.getByTestId("suggest-panel-collapse");
const expanded = await notice.boundingBox();

await page.getByTestId("annotator-root").focus();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByTestId("annotator-root")).toBeFocused();
await expectContainedBy(notice, icon);
await expectContainedBy(notice, toggle);
const minimal = await notice.boundingBox();
expect(expanded).not.toBeNull();
expect(minimal).not.toBeNull();
if (expanded !== null && minimal !== null) expect(minimal.width).toBeLessThan(expanded.width);

await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(page.getByTestId("suggest-idle")).toBeVisible();
await expectContainedBy(notice, toggle);
});

test("minimal notice controls remain contained at the supported narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 720 });
await arm(page);
const notice = page.getByTestId("suggest-panel");
const icon = page.getByTestId("suggest-panel-icon");
const toggle = page.getByTestId("suggest-panel-collapse");

await toggle.click();
await expectContainedBy(notice, icon);
await expectContainedBy(notice, toggle);
});

test("a reduced proposal keeps its decisions contained through restore", async ({ page }) => {
await showProposal(page);
const notice = page.getByTestId("suggest-panel");
const icon = page.getByTestId("suggest-panel-icon");
const toggle = page.getByTestId("suggest-panel-collapse");
const expanded = await notice.boundingBox();

await toggle.click();
const accept = page.getByTestId("suggest-accept");
const discard = page.getByTestId("suggest-discard");
await expect(accept).toBeVisible();
await expect(discard).toBeVisible();
await expect(page.getByText("Click again to refine it — alt-click to take a part away.")).toBeVisible();
await expectContainedBy(notice, icon);
await expectContainedBy(notice, toggle);
await expectContainedBy(notice, accept);
await expectContainedBy(notice, discard);
const reduced = await notice.boundingBox();
expect(expanded).not.toBeNull();
expect(reduced).not.toBeNull();
if (expanded !== null && reduced !== null) expect(reduced.height).toBeLessThan(expanded.height);

await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(accept).toBeVisible();
await expect(discard).toBeVisible();
await expectContainedBy(notice, toggle);
});

test("accept remains operable from the reduced proposal", async ({ page }) => {
await showProposal(page);
await page.getByTestId("suggest-panel-collapse").click();
await page.getByTestId("suggest-accept").click();

await expect(page.getByTestId("suggestion-shape")).toHaveCount(0);
await expect(page.getByTestId("suggest-panel-collapse")).toHaveAttribute("aria-expanded", "false");
await page.getByTestId("suggest-panel-collapse").click();
await expect(page.getByTestId("suggest-idle")).toBeVisible();
});
76 changes: 70 additions & 6 deletions frontend/ui-core/src/annotator/EditorNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
* desktop; see `MAX_WIDTH` below.
*/

import type { JSX, ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@robomous/ui-core";
import { useId, useState, type JSX, type ReactNode } from "react";

/**
* The surface's width, stated here because the number is a measurement rather
Expand Down Expand Up @@ -96,37 +98,99 @@ export interface EditorNoticeProps {
readonly tone: "calm" | "warn";
readonly icon: ReactNode;
readonly children: ReactNode;
/** Adds a compact, open-by-default toggle for notices that can obstruct the canvas. */
readonly collapsible?: boolean;
/**
* The essential controls that remain when a collapsible notice is reduced.
*
* A reduced notice clears most of the canvas without making an active decision
* unreachable.
*/
readonly reducedContent?: ReactNode;
/** The kernel's identifier, where a bug report can quote it. Never the message. */
readonly title?: string;
}

/** Keep editor shortcuts on the annotator root after a pointer activation. */
function preserveCanvasFocus(event: { preventDefault: () => void }): void {
event.preventDefault();
}

export function EditorNotice({
testId,
tone,
icon,
children,
title,
collapsible = false,
reducedContent,
}: EditorNoticeProps): JSX.Element {
const [collapsed, setCollapsed] = useState(false);
const contentId = useId();
const reduced = collapsed && reducedContent !== undefined;
const minimal = collapsed && !reduced;
const toggleLabel = collapsed
? reduced
? "Expand suggestion panel"
: "Show suggestion panel"
: reducedContent === undefined
? "Hide suggestion panel"
: "Reduce suggestion panel";
return (
<div
data-testid={testId}
data-tone={tone}
role="status"
{...(title === undefined ? {} : { title })}
className={`pointer-events-auto flex w-full gap-2 rounded-lg border p-3 text-xs shadow-lg ${
className={`pointer-events-auto grid items-start gap-2 rounded-lg border p-3 text-xs shadow-lg ${
minimal ? "w-auto grid-cols-[auto_auto]" : "grid-cols-[auto_minmax(0,1fr)_auto]"
} ${
collapsed ? "w-auto" : "w-full"
} ${
tone === "warn" ? "border-destructive/40 bg-destructive/5" : "border-border bg-card"
}`}
>
<span
className={`mt-0.5 shrink-0 ${tone === "warn" ? "text-destructive" : "text-muted-foreground"}`}
aria-hidden="true"
data-testid={`${testId}-icon`}
>
{icon}
</span>
{/* `min-w-0` lets the column shrink below its content's intrinsic width,
which is the half of the wrap rule flexbox owns — without it a long
token widens the flex item instead of breaking. */}
<div className="flex min-w-0 flex-col gap-1 wrap-anywhere">{children}</div>
{/*
The three-column layout is explicit: icon, content, control. The middle
track absorbs the full width when expanded, so the control is truly at
the right edge; reduced and minimal forms size themselves to their live
controls instead of depending on an absent content column.
*/}
<div
id={contentId}
className="flex min-w-0 flex-col gap-1 wrap-anywhere"
hidden={collapsible && collapsed && !reduced}
>
{reduced ? reducedContent : children}
</div>
{collapsible && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="self-start"
data-testid={`${testId}-collapse`}
aria-expanded={!collapsed}
aria-controls={contentId}
aria-label={toggleLabel}
title={toggleLabel}
onMouseDown={preserveCanvasFocus}
onClick={() => setCollapsed((open) => !open)}
>
{collapsed ? (
<ChevronRight className="size-4" aria-hidden="true" />
) : (
<ChevronDown className="size-4" aria-hidden="true" />
)}
</Button>
)}
</div>
);
}
70 changes: 50 additions & 20 deletions frontend/ui-core/src/annotator/SuggestPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ const BLOCKER_COPY: Readonly<
},
};

const REFINEMENT_GUIDANCE = "Click again to refine it — alt-click to take a part away.";

export function SuggestPanel({
session,
heldClass,
Expand Down Expand Up @@ -257,7 +259,7 @@ export function SuggestPanel({
*/
if (isParked(session)) {
return (
<EditorNotice testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<EditorNotice collapsible testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<p className="font-medium text-foreground" data-testid="suggest-parked">
{heldClass === null
? "Nothing selected to suggest for"
Expand Down Expand Up @@ -301,6 +303,7 @@ export function SuggestPanel({
const copy = BLOCKER_COPY[blocker];
return (
<EditorNotice
collapsible
testId="suggest-panel"
tone={copy.tone}
icon={
Expand All @@ -318,7 +321,7 @@ export function SuggestPanel({

if (session.status === "refused") {
return (
<EditorNotice testId="suggest-panel" tone="warn" icon={<TriangleAlert className="size-4" />}>
<EditorNotice collapsible testId="suggest-panel" tone="warn" icon={<TriangleAlert className="size-4" />}>
<p className="font-medium text-foreground">That suggestion could not be made</p>
{/* The server's sentence, verbatim. It is the one that carries the
install command when the cause is a missing extra. */}
Expand All @@ -339,7 +342,7 @@ export function SuggestPanel({
// lives in `usePendingIndicator` rather than here.
if (session.status === "asking") {
return (
<EditorNotice testId="suggest-panel" tone="calm" icon={<Loader2 className="size-4 animate-spin" />}>
<EditorNotice collapsible testId="suggest-panel" tone="calm" icon={<Loader2 className="size-4 animate-spin" />}>
<p className="font-medium text-foreground" data-testid="suggest-asking">
Looking at that…
</p>
Expand All @@ -358,7 +361,7 @@ export function SuggestPanel({

if (session.status === "none") {
return (
<EditorNotice testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<EditorNotice collapsible testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<p className="font-medium text-foreground" data-testid="suggest-none">
Nothing to suggest there
</p>
Expand Down Expand Up @@ -399,24 +402,26 @@ export function SuggestPanel({
*/
if (isAcceptable(session)) {
return (
<EditorNotice testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<EditorNotice
collapsible
testId="suggest-panel"
tone="calm"
icon={<Sparkles className="size-4" />}
reducedContent={
<>
<p className="font-medium text-foreground" data-testid="suggest-shown-reduced">
A shape for “{session.labelClass}”
</p>
<p className="text-muted-foreground">{REFINEMENT_GUIDANCE}</p>
<SuggestionActions onAccept={onAccept} onDiscard={onDiscard} reduced />
</>
}
>
<p className="font-medium text-foreground" data-testid="suggest-shown">
A shape for “{session.labelClass}”
</p>
<p className="text-muted-foreground">
Click again to refine it — alt-click to take a part away.
</p>
<div className="mt-1 flex gap-2">
<Button variant="default" size="sm" data-testid="suggest-accept" onClick={onAccept}>
<Check className="size-4" aria-hidden="true" />
Accept
<Chip>↵</Chip>
</Button>
<Button variant="ghost" size="sm" data-testid="suggest-discard" onClick={onDiscard}>
Discard
<Chip>Esc</Chip>
</Button>
</div>
<p className="text-muted-foreground">{REFINEMENT_GUIDANCE}</p>
<SuggestionActions onAccept={onAccept} onDiscard={onDiscard} />
<Adjustments
session={session}
open={adjusting === true}
Expand Down Expand Up @@ -453,7 +458,7 @@ export function SuggestPanel({
activeBrowserModel?.state === "activating";

return (
<EditorNotice testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
<EditorNotice collapsible testId="suggest-panel" tone="calm" icon={<Sparkles className="size-4" />}>
{!serverTabBlocked &&
(browserTabUnacquired ? (
<>
Expand Down Expand Up @@ -527,6 +532,31 @@ export function SuggestPanel({
);
}

/** The proposal decisions remain reachable in the notice's reduced form. */
function SuggestionActions({
onAccept,
onDiscard,
reduced = false,
}: {
readonly onAccept: () => void;
readonly onDiscard: () => void;
readonly reduced?: boolean;
}): JSX.Element {
return (
<div className={reduced ? "flex gap-2" : "mt-1 flex gap-2"}>
<Button variant="default" size="sm" data-testid="suggest-accept" onClick={onAccept}>
<Check className="size-4" aria-hidden="true" />
Accept
<Chip>↵</Chip>
</Button>
<Button variant="ghost" size="sm" data-testid="suggest-discard" onClick={onDiscard}>
Discard
<Chip>Esc</Chip>
</Button>
</div>
);
}

/**
* Which connection a click goes through: a line, or a picker where there is a choice.
*
Expand Down
Loading
Loading