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
7 changes: 7 additions & 0 deletions .changeset/remove-empty-project-create-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": patch
---

Remove the inline first-agent creation button from empty projects, including
projects reopened without a live session and clients connected to older servers.
Keep ordinary project session shortcuts, menu actions, and scan boundary notes.
4 changes: 4 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ opens that session's ordinary conversation and Canvas/Steps experience. A new
project's first ordinary session is initially titled **Plan Agents**; the title
does not confer a role and can be renamed like any other session.

Empty projects have no inline first-agent creation button, including after a
session ends. Use the project's session shortcut or its menu to start work;
warnings about separate checkouts that were not searched remain visible.

When a new project gains its first active root binding, Studio durably schedules
one evidence-first map bootstrap for that first session. The model reads the
current map and uses the same structured tools available to every project
Expand Down
17 changes: 13 additions & 4 deletions packages/harness/web/e2e/create-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,24 @@ test.describe("legacy-server agent creation compatibility", () => {
await expect(page.getByTestId("workflow-cancelled")).toHaveCount(0);
});

test("the empty-project row opens the same dialog", async ({ page }) => {
// One create flow, not one per door: the empty project's CTA is the same
// subject and must not be a second mechanism that drifts.
test("an empty project has no inline create action and retains its menu dialog", async ({
page,
}) => {
await page.getByTestId("rail-add-project").click();
await page
.getByTestId("folder-field-input")
.fill("/Users/demo/blank-slate");
await page.getByTestId("open-project").click();
await page.getByTestId("project-empty-blank-slate").click();
const group = page.getByTestId("workspace-group-blank-slate");
await expect(group).toBeVisible();
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 openProjectMenu(page, "blank-slate");
await page.getByTestId("project-create-agent-blank-slate").click();

await expect(page.getByTestId("create-agent-dialog")).toBeVisible();
await expect(page.getByTestId("create-agent-project")).toHaveText(
Expand Down
31 changes: 20 additions & 11 deletions packages/harness/web/e2e/open-project.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ test.describe("the header + opens a project", () => {
await expect(page.getByTestId("agent-map-empty")).toHaveText(
"Nothing generated yet",
);
await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0);
// The server-owned project-open lifecycle contributes one real ordinary
// session. Plan Agents is only that tab's initial title—never a pinned row
// or a second synthetic navigation element.
Expand Down Expand Up @@ -126,6 +127,21 @@ test.describe("the header + opens a project", () => {
await page.getByTestId("filing-group-by").selectOption("group");
await page.keyboard.press("Escape");
await expect(page.getByTestId("project-row-blank-slate")).toBeVisible();

// Ending the automatic conversation must not bring back the retired
// first-agent action beneath the still-empty project.
await page.getByTestId(`session-tab-main-${firstSessionId}`).click();
await page.getByTestId("session-menu").click();
await page.getByTestId("session-end-btn").click();
await page.getByTestId("end-session-confirm-btn").click();
await expect(page.getByTestId("session-context")).not.toHaveAttribute(
"data-session-id",
firstSessionId!,
);
await expect(group.getByTestId("project-empty-blank-slate")).toHaveCount(0);
await expect(
group.getByRole("button", { name: /^Create (the first |an )agent here$/ }),
).toHaveCount(0);
});

test("a Studio project keeps ordinary session and agent creation available", async ({
Expand All @@ -140,7 +156,10 @@ test.describe("the header + opens a project", () => {
await expect(page.getByTestId("agent-map-frame")).toBeVisible();
await expect(page.locator(".harness-terminal .xterm")).toBeVisible();

await expect(group.getByTestId("project-empty-blank-slate")).toBeVisible();
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");
Expand All @@ -165,16 +184,6 @@ test.describe("the header + opens a project", () => {
await expect(page.locator(".rail-empty")).toHaveCount(0);
});

test("the empty row does NOT appear under a merged root-agent project", async ({
page,
}) => {
// `rfq-agent` is a root that IS an agent — `projectIsEmpty` consults
// `rootAgent` precisely so its row does not get "no agents" printed under
// the agent it is showing.
await expect(page.getByTestId("workflow-rfq")).toBeVisible();
await expect(page.getByTestId("project-empty-rfq-agent")).toHaveCount(0);
});

test("opening a folder that IS an agent project registers the agent too", async ({
page,
}) => {
Expand Down
11 changes: 4 additions & 7 deletions packages/harness/web/src/components/CreateAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,8 @@ export function CreateAgentDialog({
/** Leaves for the template gallery — the clone journey this dialog does not
* own. Omitted, the link is not rendered. */
onBrowseTemplates?: () => void;
/* NO `triggerRef`. Every door into this dialog is a control that unmounts
when it is used — the project row's popover menu closes on click, the
empty-project row is replaced by the agent it creates — so a ref handed in
/* NO `triggerRef`. The project row's popover menu closes on click,
so a ref to the menu item handed in
here would point at a detached node and Escape would restore focus to
<body> anyway, only less obviously. Same reason the rail's remove-confirm
takes the `⋮` itself rather than the menu item. */
Expand Down Expand Up @@ -120,10 +119,8 @@ export function CreateAgentDialog({
per private project name (lib/analytics/before-send.ts,
USER_NAMED_OBJECTS). */
tracking={{ dialog: "create_agent", object: "workspace" }}
/* NO `triggerRef`. Every door into this dialog is a control that unmounts
when it is used — the project row's popover menu closes on click, the
empty-project row is replaced by the agent it creates — so a ref handed
in here would point at a detached node. */
/* NO `triggerRef`. The project row's popover menu closes on click,
so a ref to the menu item would point at a detached node. */
actions={
<>
<button className="btn-ghost" disabled={busy} onClick={onCancel}>
Expand Down
83 changes: 22 additions & 61 deletions packages/harness/web/src/components/WorkflowsRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ interface WorkflowsRailProps {
* itself, its agents, and the session cwds under it — minus any project
* opened separately inside it. See `lib/project-membership.ts`. */
closedProjects: string[];
/** Checkouts a scan of each root declined to enter — see the empty-project row. */
/** Checkouts a scan of each root declined to enter — shown in a note row. */
unsearchedCheckouts: Record<string, string[]>;
/** Removes a project: out of `recentDirs`, out of the rail, and the live
* sessions rooted in it end. Nothing on disk is touched. */
Expand Down Expand Up @@ -1500,74 +1500,35 @@ export function WorkflowsRail({
depth={0}
/>
)}
{/* AN EMPTY PROJECT SAYS SO, on its own row.
`projectIsEmpty` is the one emptiness answer and it consults
`rootAgent` — a merged root-agent project has nothing in
`dirs` or `agents` and a naive check would print this line
under an agent row. `creating` already has its own spinner,
and a bare project with a live session already has its
Scaffold affordance, so neither reaches this. */}
{!collapsed && empty && !creating && bare == null && (
<>
<div className="workspace-row is-nested workspace-row-empty">
{/* Preserve the scan boundary explanation for an empty project.
First-agent creation belongs to the project menu; an empty
project never gets a separate inline creation action. */}
{!collapsed &&
empty &&
!creating &&
bare == null &&
(unsearchedCheckouts[project.root]?.length ?? 0) > 0 && (
<div className="workspace-row is-nested">
<span
className="row-disclosure row-disclosure-static"
aria-hidden="true"
/>
<button
type="button"
className="tree-row tree-row-empty-action"
data-testid={`project-empty-${project.label}`}
data-tooltip={`Start an agent in ${project.root}`}
onClick={() =>
onCreateAgent(project.root, project.label)
}
<div
className="tree-row tree-row-note"
data-testid={`project-unsearched-${project.label}`}
title={`Open one as its own project to see its agents:\n${unsearchedCheckouts[
project.root
]!.join("\n")}`}
>
<Icon name="Sparkles" size={13} />
<Icon name="GitBranch" size={13} />
<span className="tree-row-label">
{(unsearchedCheckouts[project.root]?.length ?? 0) > 0
? "Create an agent here"
: "Create the first agent here"}
{unsearchedCheckouts[project.root]!.length === 1
? "1 checkout not searched"
: `${unsearchedCheckouts[project.root]!.length} checkouts not searched`}
</span>
</button>
</div>
{/* THE BOUNDARY'S OWN ANSWER, when there is one.
A scan stops at every separate checkout, so a folder that
is not itself a repo but holds several clones finds
NOTHING while the agents are right there. Rendering only
"Create the FIRST agent here" over that folder states
something false, and falsely in the worst direction: it
tells the user the agents they can see on disk do not
exist. The count is the difference between "there is
nothing here" and "I did not look in there". */}
{(unsearchedCheckouts[project.root]?.length ?? 0) > 0 && (
<div className="workspace-row is-nested">
<span
className="row-disclosure row-disclosure-static"
aria-hidden="true"
/>
<div
className="tree-row tree-row-note"
data-testid={`project-unsearched-${project.label}`}
/* The ROW states the fact; the tooltip carries the
remedy and the paths. A 320px rail cannot hold both
in one line, and truncating the remedy would leave
the fact looking like a dead end. */
title={`Open one as its own project to see its agents:\n${unsearchedCheckouts[
project.root
]!.join("\n")}`}
>
<Icon name="GitBranch" size={13} />
<span className="tree-row-label">
{unsearchedCheckouts[project.root]!.length === 1
? "1 checkout not searched"
: `${unsearchedCheckouts[project.root]!.length} checkouts not searched`}
</span>
</div>
</div>
)}
</>
)}
</div>
)}
{!collapsed && axis === "project" && (
<ProjectTreeRows
dirs={project.dirs}
Expand Down
37 changes: 2 additions & 35 deletions packages/harness/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10829,26 +10829,6 @@ button.system-graph-node.is-navigable:focus-visible {
white-space: nowrap;
}

/* An empty project's own line. Nested to the depth its agents would occupy,
so the row reads as "this is where they would be" rather than as a second
header. Non-interactive: there is nothing here to click, and a hover state
would promise otherwise. */
.workspace-row-empty {
pointer-events: none;
}

/* …EXCEPT the action inside it (SAP-2981). The inert row predates it carrying
a button: the empty project used to be a label that stated a fact, and the
rule kept it from eating hover on the rows around it. Once "Create the first
agent here" moved in, the same rule swallowed every click on it — measured,
`elementFromPoint` over the middle of that button returned the enclosing
`.workspace-group`, and Playwright's click retried for 30s against a control
that could never receive one. The spec beside it only ever asserted
`toBeEnabled()`, which a control nobody can click passes. */
.workspace-row-empty .tree-row-empty-action {
pointer-events: auto;
}

.tree-row-empty {
padding-left: var(--tree-pad-x);
color: var(--text-faint);
Expand Down Expand Up @@ -10885,21 +10865,8 @@ button.system-graph-node.is-navigable:focus-visible {
padding: 0 3px;
}

/* The empty project's action row. It uses the shared `.tree-row` skeleton so it
sits on the agent rows' rhythm — this is where the first agent will appear,
and the row should already read as that place. */
.tree-row-empty-action {
color: var(--text-faint);
font-size: var(--type-meta);
}

.tree-row-empty-action:hover {
color: var(--text);
background: var(--bg-hover);
}

/* A NOTE ROW, not an action. The empty-project row's companion when a scan
stopped at separate checkouts: it states a fact the user needs in order to
/* A NOTE ROW, not an action. When a scan stopped at separate checkouts,
it states a fact the user needs in order to
read the empty project correctly, and it is deliberately NOT clickable —
which checkout to open is the user's decision, and guessing one would be the
same overreach as the walk entering all of them. */
Expand Down