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
11 changes: 11 additions & 0 deletions .changeset/project-row-plus-is-new-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@sapiom/harness": minor
---

A project row's `+` is **New agent**, scoped to that project, and a plain session is no longer a row verb.

Creation had been delegated to the pinned Agent Map: `mapOwnsCreation` is true for every project on a current server, and it gated both the row's create action and the empty project's create row, so neither rendered. The Agent Map has no create control of its own — its only route to generating agents was the planner session, which SAP-3143 removes. A project that already held agents was left with no scoped way to grow another; the rail's top CTA opens the composer with no project context and cannot create into an existing project.

The `+` now opens the new-agent screen for the row's own project (`project-create-agent-{label}`), and a bare project keeps its distinct scaffold verb (`workspace-scaffold-{label}`). `project-start-session-{label}` is removed: a plain session starts from the tab strip, or from the **Start a session** on the project's own pane. The empty project still gets no create row of its own — its Agent Map row is the CTA.

Follows design-eng `IA.md` 219 and D34(a); D34(e) and D35 item 6 for sessions belonging to the tab strip.
9 changes: 9 additions & 0 deletions .changeset/project-row-remove-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@sapiom/harness": patch
---

A project row's remove verb is a hover action, not an overflow menu. The `⋮` on every project row opened a 248px card to hold a single item — on plan-first projects its create item is suppressed, because the Agent Map owns creation, so the popover existed to carry one `Remove … from the rail`. That verb is now an `X` beside the session shortcut, hover-revealed like every other row action, and it opens the same confirmation as before: the project named, the count of running sessions it will end, and the statement that nothing on disk is touched.

Row actions state their subject in the accessible name and the tooltip rather than in visible menu text. The `project-remove-{label}` testid is unchanged and now belongs to the button itself; `project-menu-{label}` and `project-menu-card-{label}` are gone, as is the `openProjectMenu` e2e helper.

Follows design-eng D33: a project row's verbs are hover actions on the header, and a per-row menu would be a new idiom.
72 changes: 29 additions & 43 deletions packages/harness/web/e2e/accumulation-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";

import { openProjectMenu } from "./mock-navigation";

const ACME = "/Users/demo/acme-app";

Expand Down Expand Up @@ -98,62 +97,55 @@ test.describe("Remove project", () => {
// A destructive action standing at full strength on every project row
// would be the loudest thing in the rail; invisible even to the keyboard
// would be worse. Both halves are CSS, so both are asserted on screen.
// Remove lives inside the row's ⋮ (SAP-2982); the session shortcut sits
// beside that menu and shares the same row-owned reveal contract.
// Remove is a row action of its own now, beside the session shortcut, and
// every action on the row shares one reveal contract — asserted across the
// whole set rather than a fixed count, so adding or removing a verb cannot
// quietly leave one of them standing.
const row = page.getByTestId("workspace-group-acme-app").locator(":scope > .workspace-row");
const actions = row.locator(":scope > .workspace-row-action");
const opacities = (): Promise<string[]> =>
actions.evaluateAll((elements) =>
elements.map((element) => getComputedStyle(element).opacity),
);
const count = await actions.count();
expect(count).toBeGreaterThan(1);
const all = (value: string): string[] => Array(count).fill(value);

expect(await opacities()).toEqual(["0", "0"]);
expect(await opacities()).toEqual(all("0"));
await row.hover();
await expect.poll(opacities).toEqual(["1", "1"]);
await expect.poll(opacities).toEqual(all("1"));

await page.mouse.move(0, 0);
await expect.poll(opacities).toEqual(["0", "0"]);
await actions.first().focus();
await expect
.poll(() =>
actions.first().evaluate((element) => getComputedStyle(element).opacity),
)
.toBe("1");
await actions.nth(1).focus();
await expect
.poll(() =>
actions.nth(1).evaluate((element) => getComputedStyle(element).opacity),
)
.toBe("1");
});

test("an OPEN menu holds its trigger on screen after the pointer leaves", async ({ page }) => {
// The popover is anchored to the ⋮. Letting the trigger fade back to
// opacity 0 when the pointer leaves the row leaves a card floating beside
// nothing — the anchor is invisible and the menu looks unmoored.
const menu = page.getByTestId("project-menu-acme-app");
await menu.click();
await page.mouse.move(0, 0);
await expect(page.getByTestId("project-menu-card-acme-app")).toBeVisible();
await expect
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
await expect.poll(opacities).toEqual(all("0"));
// Keyboard reveal, one control at a time: a row action the pointer never
// touches must still show itself when it takes focus.
for (let index = 0; index < count; index += 1) {
await actions.nth(index).focus();
await expect
.poll(() =>
actions
.nth(index)
.evaluate((element) => getComputedStyle(element).opacity),
)
.toBe("1");
}
});

test("a COLLAPSED project row does not grow a standing ", async ({ page }) => {
test("a COLLAPSED project row does not grow a standing remove", async ({ page }) => {
// `.workspace-row.is-collapsed .workspace-row-action[aria-expanded]` tests
// only that the attribute is PRESENT, and a menu trigger always carries
// one — so without the exclusion in styles.css every collapsed project row
// wore a permanent ⋮, which is exactly the standing control the rail's
// hover-reveal exists to avoid.
// only that the attribute is PRESENT. The ⋮ this replaced always carried
// one, so every collapsed project row wore a permanent overflow until an
// exclusion was added for it. Plain row actions carry no `aria-expanded`,
// so the destructive one must stay hidden at rest on its own — the standing
// control the rail's hover-reveal exists to avoid.
await page.getByTestId("project-disclosure-acme-app").click();
const row = page.getByTestId("workspace-group-acme-app").locator(":scope > .workspace-row");
await expect(row).toHaveClass(/is-collapsed/);
await page.mouse.move(0, 0);
await expect
.poll(() =>
page
.getByTestId("project-menu-acme-app")
.getByTestId("project-remove-acme-app")
.evaluate((element) => getComputedStyle(element).opacity),
)
.toBe("0");
Expand All @@ -162,7 +154,6 @@ test.describe("Remove project", () => {
test("the confirm NAMES the number of sessions it ends, and says nothing on disk is touched", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
const confirm = page.getByTestId("remove-project-confirm");
await expect(confirm).toBeVisible();
Expand All @@ -179,15 +170,13 @@ test.describe("Remove project", () => {
test("says so plainly when there is nothing to end", async ({ page }) => {
// rfq-agent has one exited session and no live one. An abstract warning
// here would be a lie in the only direction that matters.
await openProjectMenu(page, "rfq-agent");
await page.getByTestId("project-remove-rfq-agent").click();
await expect(page.getByTestId("remove-project-confirm-count")).toHaveText(
"No running sessions to end.",
);
});

test("Keep project changes nothing", async ({ page }) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await page.getByRole("button", { name: "Keep project" }).click();
await expect(page.getByTestId("remove-project-confirm")).toHaveCount(0);
Expand All @@ -203,7 +192,6 @@ test.describe("Remove project", () => {
await expect(page.getByTestId("workflow-leasing")).toBeVisible();
expect(await recentDirPaths(page)).toContain(ACME);

await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await page.getByTestId("remove-project-confirm-btn").click();

Expand Down Expand Up @@ -247,7 +235,6 @@ test.describe("Remove project", () => {
// into a hidden project would leave an agent that exists and nothing
// shows; giving it a root of its own would mint `acme-app/leasing`, which
// is the accumulation this ticket closes.
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await page.getByTestId("remove-project-confirm-btn").click();
await expect(page.getByTestId("workspace-group-acme-app")).toHaveCount(0);
Expand All @@ -271,7 +258,6 @@ test.describe("Remove project", () => {
// so it cannot be confused with the `workers` subdirectory row inside it.
await expect(page.getByTestId("workspace-group-polsia/services/workers")).toBeVisible();

await openProjectMenu(page, "polsia");
await page.getByTestId("project-remove-polsia").click();
await page.getByTestId("remove-project-confirm-btn").click();

Expand Down
9 changes: 0 additions & 9 deletions packages/harness/web/e2e/create-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";

import { openProjectMenu } from "./mock-navigation";

const ROOT = "/Users/demo/acme-app";

Expand Down Expand Up @@ -59,7 +58,6 @@ test.describe("legacy-server agent creation compatibility", () => {
test("the menu opens a dialog that STATES the project, and starts nothing", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();

const dialog = page.getByTestId("create-agent-dialog");
Expand All @@ -81,7 +79,6 @@ test.describe("legacy-server agent creation compatibility", () => {
});

test("creation completes BEFORE the session starts", async ({ page }) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
await page.getByTestId("create-agent-name").fill("billing-bot");
await page.getByTestId("create-agent-submit").click();
Expand All @@ -102,7 +99,6 @@ test.describe("legacy-server agent creation compatibility", () => {
test("a first instruction reaches the session, and never asks for a scaffold", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
await page.getByTestId("create-agent-name").fill("digest-bot");
await page
Expand All @@ -125,7 +121,6 @@ test.describe("legacy-server agent creation compatibility", () => {
test("a duplicate name is refused by the SERVER, in the dialog, and nothing starts", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
// `leasing` is a fixture agent in this project. The field has no opinion
// about it — only the endpoint knows what is already there.
Expand All @@ -150,7 +145,6 @@ test.describe("legacy-server agent creation compatibility", () => {
test("a name that is not one folder segment is refused before it is sent", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
const name = page.getByTestId("create-agent-name");
const submit = page.getByTestId("create-agent-submit");
Expand Down Expand Up @@ -181,7 +175,6 @@ test.describe("legacy-server agent creation compatibility", () => {
test("Return submits from the name field — and Return on Cancel cancels", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
await page.getByTestId("create-agent-name").fill("returned");
await page.getByTestId("create-agent-name").press("Enter");
Expand All @@ -190,7 +183,6 @@ test.describe("legacy-server agent creation compatibility", () => {
// The dialog took Return for the whole form, so a focused Cancel took it
// too: pressing Return on "Cancel" closed the dialog AND created the
// agent — the opposite of what was pressed. Measured, before the guard.
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
await page.getByTestId("create-agent-name").fill("cancelled");
await page.getByRole("button", { name: "Cancel" }).focus();
Expand All @@ -215,7 +207,6 @@ test.describe("legacy-server agent creation compatibility", () => {
name: /^Create (the first |an )agent here$/,
}),
).toHaveCount(0);
await openProjectMenu(page, "blank-slate");
await page.getByTestId("project-create-agent-blank-slate").click();

await expect(page.getByTestId("create-agent-dialog")).toBeVisible();
Expand Down
8 changes: 4 additions & 4 deletions packages/harness/web/e2e/dialog-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";

import { openProjectMenu } from "./mock-navigation";

interface DialogCase {
name: string;
Expand Down Expand Up @@ -67,12 +66,11 @@ const CASES: DialogCase[] = [
open: async (page) => {
await page.goto("/");
await expect(page.locator(".rail-workflows")).toBeVisible();
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await expect(page.getByTestId("remove-project-confirm")).toBeVisible();
},
surface: (page) => page.getByTestId("remove-project-confirm"),
trigger: (page) => page.getByTestId("project-menu-acme-app"),
trigger: (page) => page.getByTestId("project-remove-acme-app"),
// The SAFE action, on a destructive dialog: Enter keeps the project.
opensFocusedOn: (page) => page.getByRole("button", { name: "Keep project" }),
behind: (page) => page.getByTestId("rail-create-new"),
Expand All @@ -82,11 +80,13 @@ const CASES: DialogCase[] = [
open: async (page) => {
await page.goto("/?seed=0&mockStudioProjects=absent");
await expect(page.getByTestId("workspace-group-acme-app")).toBeVisible();
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-create-agent-acme-app").click();
await expect(page.getByTestId("create-agent-dialog")).toBeVisible();
},
surface: (page) => page.getByTestId("create-agent-dialog"),
// The row action survives the dialog now. It used to be a menu item that
// unmounted with its popover, so focus had nowhere to go but the document.
trigger: (page) => page.getByTestId("project-create-agent-acme-app"),
opensFocusedOn: (page) => page.getByTestId("create-agent-name"),
behind: (page) => page.getByTestId("rail-create-new"),
},
Expand Down
13 changes: 0 additions & 13 deletions packages/harness/web/e2e/mock-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,3 @@ export async function selectMockSessionFromPalette(
await item.click();
}

/**
* Open a project row's ⋮ menu.
*
* Every action a project row offers now lives behind one control (SAP-2982).
* `+` and `×` used to sit on the row itself — adjacent, same size, same
* hover-reveal — while acting on different nouns: `+` created an AGENT in the
* project, `×` removed the PROJECT. A menu of named items has no adjacency to
* misread, and the specs open it before acting.
*/
export async function openProjectMenu(page: Page, label: string): Promise<void> {
await page.getByTestId(`project-menu-${label}`).click();
await expect(page.getByTestId(`project-menu-card-${label}`)).toBeVisible();
}
17 changes: 6 additions & 11 deletions packages/harness/web/e2e/open-project.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
*/
import { expect, test } from "@playwright/test";

import { openProjectMenu } from "./mock-navigation";

/* A folder that is NOTHING yet: no agent, no session, no recentDirs entry.
`scratch` cannot play this part — it is the fixture's bare-session project,
Expand Down Expand Up @@ -156,26 +155,24 @@ test.describe("the header + opens a project", () => {
await expect(page.getByTestId("agent-map-frame")).toBeVisible();
await expect(page.locator(".harness-terminal .xterm")).toBeVisible();

// D36: an empty project gets no create ROW of its own — its Agent Map row
// is the CTA. The row's `+` is a different control and is always there.
await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0);
await expect(
group.getByRole("button", { name: /^Create (the first |an )agent here$/ }),
).toHaveCount(0);
await expect(
group.getByTestId("project-start-session-blank-slate"),
).toHaveAttribute("aria-label", "Start a session in blank-slate");

// The map is a view, not an authorization gate. Both direct agent creation
// and project removal remain ordinary project-level actions.
await openProjectMenu(page, "blank-slate");
// New agent, scoped to this project, on the row itself (IA.md 219, D34a).
// A plain session is not a row verb: the tab strip owns it, and the
// project's own pane carries the Start (D34e, D35 item 6).
await expect(
page.getByTestId("project-create-agent-blank-slate"),
).toBeVisible();
).toHaveAttribute("aria-label", "Create an agent in blank-slate");
await expect(page.getByTestId("project-remove-blank-slate")).toBeVisible();
await page.keyboard.press("Escape");

// A bare project with an existing ordinary session retains its scaffold
// action too.
await openProjectMenu(page, "scratch");
await expect(page.getByTestId("workspace-scaffold-scratch")).toBeVisible();
await expect(page.getByTestId("project-remove-scratch")).toBeVisible();

Expand Down Expand Up @@ -283,7 +280,6 @@ test.describe("round trip: removed, then back", () => {
const before = await projectRows(page);
expect(before).toContain("project-row-acme-app");

await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await page.getByTestId("remove-project-confirm-btn").click();
await expect(page.getByTestId("project-row-acme-app")).toHaveCount(0);
Expand Down Expand Up @@ -325,7 +321,6 @@ test.describe("round trip: removed, then back", () => {
test("opening a folder ABOVE a removed project un-hides what is inside it", async ({
page,
}) => {
await openProjectMenu(page, "acme-app");
await page.getByTestId("project-remove-acme-app").click();
await page.getByTestId("remove-project-confirm-btn").click();
await expect(page.getByTestId("workflow-leasing")).toHaveCount(0);
Expand Down
Loading