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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **Every sister agent gets the fleet surfaces, not just the host console (ADR 0048 §8).**
"New agent" and "Fleet settings" in the header switcher, the ⌘K **Fleet Room**, and
**Settings ▸ Box ▸ Fleet** all render in a fleet member's window now. They were gated to
the host to stop *nested* agents, but that fear never applied here: `/api/fleet` and
`/api/archetypes` are hub paths (never slug-scoped), so from `/app/agent/<slug>/` those
surfaces were already driving the **hub's** fleet — the same roster, the same members to
start and stop, the same fleet to create into. Open a sister agent, and the fleet is
reachable without a trip back to the host window.

The gate now asks the one question that actually distinguishes the nesting case: is this
a spawned member reached **directly on its own port**? There its `/api/fleet` really is a
fleet-of-one and creating would spawn a grandchild the hub never sees, so the items stay
disabled with a tooltip pointing at the host.

Two guards came along with it, since they only became reachable once member windows had
these panels: a window can no longer **Stop** or **Remove** the agent that serves it (the
fleet panel row and the Fleet Room roster both keyed on "is the host" — correct only on
the host console, and a Stop on your own row killed the console you were standing in).
The Box group narrows rather than disappears off the host — Overview and Telemetry read
the focused agent's own endpoints and stay host-only — which is also what makes the
"Fleet settings" deep-link resolve instead of falling back to an unrelated section.

## [0.115.0] - 2026-07-25

### Added
Expand Down
53 changes: 53 additions & 0 deletions apps/web/e2e/fleet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,56 @@ test("⌘K → Fleet Room: a TYPED @name addresses that member without using the
await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible();
await expect(page.locator(".pl-toast", { hasText: /Broadcast to/ })).toHaveCount(0);
});

// ── Sister agents get the fleet surfaces too (#1708/#1999 revisited) ───────────────────
// The three affordances used to be host-console-only, on the theory that a member window
// would be managing a fleet-of-one and could only nest. That's false for a slug window:
// /api/fleet + /api/archetypes are HUB paths (lib/api.ts `isHubPath`), so a sister agent's
// console drives the SAME fleet the host does. These pin that it stays reachable there —
// and that the window can't act on the agent serving it.

// Assert by ACTING, not by reading an aria-disabled attribute: a disabled DS MenuItem is
// pointer-events:none, so a click that lands is proof the item is live — and it also proves
// the deep-link RESOLVES, which is the half that was actually broken (the Box group was
// dropped wholesale off the host, so "Fleet settings" fell back to some other section).
test("a sister agent's window: Fleet settings opens the fleet panel from the switcher", async ({ page }) => {
await page.goto("/app/agent/ava/", { waitUntil: "load" });
await page.getByTestId("fleet-switcher").click();
await page.getByRole("menuitem", { name: "Fleet settings" }).click();
await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible();
});

test("a sister agent's window: New agent opens the archetype picker from the switcher", async ({ page }) => {
await page.goto("/app/agent/ava/", { waitUntil: "load" });
await page.getByTestId("fleet-switcher").click();
await page.getByRole("menuitem", { name: "New agent" }).click();
await expect(page.getByRole("heading", { name: "New agent" })).toBeVisible();
});

test("a sister agent's window: the ⌘K Fleet Room opens on the hub's roster", async ({ page }) => {
await page.goto("/app/agent/ava/", { waitUntil: "load" });
await openFleetRoom(page);
const room = page.locator(".flr");
// The hub's real roster — its siblings are here, not an empty fleet-of-one.
await expect(room.locator(".flr__member", { hasText: "main" })).toBeVisible();
await expect(room.locator(".flr__member", { hasText: "roxy" })).toBeVisible();
// But no Stop on its OWN row: that button would kill the agent serving this window.
await expect(
room.locator(".flr__member", { hasText: "ava" }).getByRole("button", { name: /^(Stop|Start) ava$/ }),
).toHaveCount(0);
// A sibling still toggles normally.
await expect(room.locator(".flr__member", { hasText: "roxy" }).getByRole("button", { name: "Start roxy" })).toBeVisible();
});
Comment on lines +423 to +436

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files matching fleet spec:"
fd -a 'fleet\.spec\.ts$' . || true

echo
echo "Relevant fleet.spec.ts excerpts:"
if [ -f apps/web/e2e/fleet.spec.ts ]; then
  sed -n '390,460p' apps/web/e2e/fleet.spec.ts | nl -ba -v390
fi

echo
echo "Search for similar member button expectations in fleet spec:"
rg -n 'flr__member|toBeVisible\(\)|toBeEnabled\(\)|Start .*ava|Start .*roxy|Stop .*ava|Stop .*roxy' apps/web/e2e/fleet.spec.ts || true

echo
echo "Check Playwright version if present:"
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock; do
  [ -e "$f" ] && echo "--- $f" && rg -n '"`@playwright/test`"|"playwright"|"playwright-core"|version:' "$f" | head -40 || true
done

Repository: protoLabsAI/protoAgent

Length of output: 313


🏁 Script executed:

#!/bin/bash
set -euo pipefail

REPO=/home/jailuser/git

echo "Relevant fleet.spec.ts excerpts with line numbers:"
awk 'NR>=390 && NR<=460 { printf "%6d\t%s\n", NR, $0 }' "$REPO/apps/web/e2e/fleet.spec.ts"

echo
echo "Search for member button expectations in fleet.spec.ts:"
rg -n 'flr__member|toBeVisible|toBeEnabled|Start .*ava|Start .*roxy|Stop .*ava|Stop .*roxy' "$REPO/apps/web/e2e/fleet.spec.ts" || true

echo
echo "Playwright version references:"
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock; do
  [ -e "$f" ] && {
    echo "--- $f"
    rg -n '"`@playwright/test`"|"playwright"|"playwright-core"|version:' "$f" | head -60 || true
  }
done

echo
echo "Behavioral probe: toHaveCount(0) and toHaveCount(1) with disabled visible elements in current env if installed:"
node - <<'JS'
try {
  const pw = require('`@playwright/test`');
  console.log("`@playwright/test` module:", pw && typeof pw.test);
} catch (e) {
  console.log("`@playwright/test` module: unavailable in NODE_PATH");
}
JS

Repository: protoLabsAI/protoAgent

Length of output: 9925


🌐 Web query:

Playwright toHaveCount matcher does disabled elements count visible to match visible assertions

💡 Result:

In Playwright, the toHaveCount assertion counts all elements that match the provided locator, regardless of whether they are disabled or hidden [1][2]. It does not automatically filter out elements based on their visibility or enabled state [2]. If you need to count only visible elements or only enabled elements, you must refine your locator to explicitly include those criteria: For visible elements: Use the:visible CSS pseudo-class or the.filter({ visible: true }) method [3][2][4]. Example: await expect(page.locator('button:visible')).toHaveCount(3); Example: await expect(page.locator('button').filter({ visible: true })).toHaveCount(3); For enabled elements: Use the:disabled CSS pseudo-class or filter by state [5]. Example: await expect(page.locator('button:not(:disabled)')).toHaveCount(2); By default, standard locators match all elements present in the DOM that satisfy the selector, including those that are disabled (e.g., have a [disabled] attribute) or hidden (e.g., have display: none) [1][2]. The toHaveCount assertion simply asserts that the total count of matches meets your expectation [6][7].

Citations:


🌐 Web query:

Playwright toBeVisible assertion button disabled default aria-disabled ignored

💡 Result:

In Playwright, the toBeVisible assertion checks only for element visibility (a non-empty bounding box and no visibility:hidden style) [1][2][3]. It does not check whether an element is enabled or disabled [4][5]. Regarding your query about disabled states: 1. toBeVisible and disabled state: The toBeVisible assertion is completely independent of whether a button is disabled, enabled, or has aria-disabled attributes [4][1][5]. An element can be both visible and disabled at the same time [1][2]. 2. Playwright's definition of "disabled": Playwright considers an element disabled if it meets specific criteria [1][3]. Crucially, for descendants of an element with the aria-disabled=true attribute, Playwright recognizes the element as disabled [1][6][2]. 3. Default behavior: Playwright does not ignore aria-disabled. If a button is a descendant of an element with aria-disabled="true", Playwright correctly identifies it as disabled [1][3]. If you are experiencing issues where Playwright is not respecting aria-disabled, ensure that the locator is targeting the correct element and that the attribute is correctly applied to a parent in the DOM hierarchy [6]. If you need to verify that a button is disabled, you should use the dedicated toBeDisabled assertion rather than relying on visibility checks [4][7]. Summary of Key Assertions: - toBeVisible: Checks if the element is visible in the DOM (non-empty bounding box, not visibility:hidden) [1][3]. - toBeDisabled: Checks if the element is disabled via the disabled attribute, fieldset, or parent aria-disabled="true" attribute [4][1][3].

Citations:


Assert sibling controls are enabled, not merely rendered.

toBeVisible() only checks DOM visibility, so a disabled sibling Start button can still pass. Append toBeEnabled() after the visibility asserts at Line 435 and Line 448 to catch a guard that disables all fleet controls instead of only the current agent’s own controls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/e2e/fleet.spec.ts` around lines 423 - 436, The Fleet Room test
around openFleetRoom must verify sibling controls are usable, not only visible.
After the visibility assertions for the sibling member controls in the test “a
sister agent's window: the ⌘K Fleet Room opens on the hub's roster” (including
the roxy Start button), add enabled-state assertions with toBeEnabled while
preserving the existing own-agent control exclusion.


test("a sister agent's window: the fleet panel won't stop or remove the agent serving it", async ({ page }) => {
await page.goto("/app/agent/ava/", { waitUntil: "load" });
await page.getByTestId("header-menu").click();
await page.getByTestId("app-drawer").getByRole("button", { name: "Settings", exact: true }).click();
await page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Fleet", exact: true }).click();
const self = page.locator(".fleet-row", { hasText: "ava" });
await expect(self).toBeVisible();
await expect(self.getByRole("button", { name: "Stop" })).toHaveCount(0);
await expect(self.getByRole("button", { name: "Remove" })).toHaveCount(0);
// A sibling keeps its controls — the guard is about SELF, not about being a member window.
await expect(page.locator(".fleet-row", { hasText: "roxy" }).getByRole("button", { name: "Start" })).toBeVisible();
});
21 changes: 17 additions & 4 deletions apps/web/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,10 @@ test("a host-scoped edit on the host console saves to the host layer", async ({
});

// On a FLEET MEMBER console (/agent/<slug>/) the same fields show the ADR 0047 inheritance
// view instead — inherited-from / overridden-here badges + reset-to-inherited. There is no
// Box group on a member.
// view instead — inherited-from / overridden-here badges + reset-to-inherited. The Box group
// narrows to Fleet there (see the next test).
test("per-agent (fleet member) settings show ADR 0047 inheritance badges + reset", async ({ page }) => {
await openSettings(page, "/app/agent/ava/");
// No Box group on a fleet member.
await expect(page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Fleet", exact: true })).toHaveCount(0);
await section(page, "Model");
await expandAllGroups(page);
await expect(page.locator('.setting-row[data-key="model.name"] .setting-inheritance')).toContainText(
Expand All @@ -195,6 +193,21 @@ test("per-agent (fleet member) settings show ADR 0047 inheritance badges + reset
await expect(page.locator('.setting-row[data-key="routing.fallback_models"] .setting-inheritance')).toHaveCount(0);
});

// The Box group NARROWS on a sister agent's console, it doesn't vanish: Fleet stays (it names
// the hub's fleet from any window — /api/fleet is a hub path), while Overview + Telemetry read
// the focused agent's own endpoints and remain host-console-only.
test("a sister agent's console keeps Box ▸ Fleet, without the agent-scoped Box sections", async ({ page }) => {
await openSettings(page, "/app/agent/ava/");
const sidenav = page.locator(".settings-overlay .pl-sidenav");
await expect(sidenav.getByRole("tab", { name: "Fleet", exact: true })).toBeVisible();
await expect(sidenav.getByRole("tab", { name: "Overview", exact: true })).toHaveCount(0);
await expect(sidenav.getByRole("tab", { name: "Telemetry", exact: true })).toHaveCount(0);
// And it's the real panel — the hub's roster, reachable from here.
await sidenav.getByRole("tab", { name: "Fleet", exact: true }).click();
await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible();
await expect(page.locator(".fleet-row", { hasText: "roxy" })).toBeVisible();
});

test("Identity: a name change saves via /api/settings, not /api/config (no operator clobber)", async ({ page }) => {
// The Identity panel routes the name through the canonical settings cascade; SOUL (unchanged
// here) is what goes via /api/config. The old code ALWAYS POSTed /api/config echoing a cached
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/app/FleetRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
// The bottom bar broadcasts to everyone online (the @everyone announce — the only
// fire-and-forget path, since you can't stream N replies into one pane).
//
// Entered from the palette's "Agents" group; the DS CommandPalette supplies the
// back/close chrome + footer. Ungated for now — it reflects whatever api.fleet() returns
// for this window; host-scoping (cf. fleetSettingsGate) can follow.
// Entered from the palette's "Agents" group; the DS CommandPalette supplies the back/close
// chrome + footer. Open in every sister agent's window as well as the host's: `/api/fleet` is
// a hub path (never slug-scoped), so the roster it reflects is the same one from anywhere.
// The one window it's withheld from is a member reached DIRECTLY on its own port, where the
// fleet really is a fleet-of-one — see fleetSettingsGate.
import { useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { ExternalLink, Play, Radio, Send, Square } from "lucide-react";
Expand Down Expand Up @@ -226,7 +228,11 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl
{roster.map((a) => {
const slug = slugOf(a);
const p = presenceOf(a);
const local = !a.host && !a.remote; // only a local process can be started/stopped here
// Only a local process can be started/stopped here — and never the agent serving
// THIS window (`here`): in a sister agent's room its own row would otherwise offer
// a Stop that kills the console you're standing in. The host row is already
// excluded on the host console; `here` is what covers a member's window.
const local = !a.host && !a.remote && slug !== here;
return (
<div key={slug} className={`flr__member${a.running ? "" : " is-down"}`}>
<span className={`flr__dot flr__dot--${p.key}`} aria-hidden />
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/app/FleetSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export function FleetSwitcher({
const agents = fleet.data?.agents ?? [];
const slug = currentSlug(); // the agent THIS window is on
const current = agents.find((a) => slugOf(a) === slug);
// Fleet settings are hub-only (#1708): non-null in a member window (a hub slug window,
// or a spawned workspace member reached directly) — the item disables with this tooltip.
const fleetSettingsBlocked = fleetSettingsDisabledReason(agents, slug);
// Non-null only on a spawned member reached DIRECTLY on its own port (#1708/#1999) — the
// one window where managing "the fleet" would spawn a nested one. A sister agent's slug
// window drives the hub's fleet and keeps both items live.
const fleetSettingsBlocked = fleetSettingsDisabledReason(agents);

// Only a hard fleet-API error hides the switcher; otherwise it's always available.
if (fleet.isError) return <>{fallbackName}</>;
Expand Down Expand Up @@ -71,9 +72,9 @@ export function FleetSwitcher({
})}
{agents.length > 0 ? <MenuSeparator /> : null}
{fleetSettingsBlocked ? (
// Adding a member is a host-only op, same as Fleet settings (a member window's
// /api/fleet is a fleet-of-one "New agent" there would spawn a nested fleet by
// accident, #1999). Disabled + tooltip, not hidden, so it stays discoverable.
// Adding a member follows the same gate as Fleet settings: on a directly-reached
// member /api/fleet is a fleet-of-one, so "New agent" there would spawn a nested
// fleet by accident (#1999). Disabled + tooltip, not hidden, so it stays discoverable.
<Tooltip label={fleetSettingsBlocked} side="left">
<MenuItem icon={<Plus size={14} />} disabled>
New agent
Expand Down
31 changes: 20 additions & 11 deletions apps/web/src/app/fleetSettingsGate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// "Fleet settings" gate (#1708): the header dropdown item is hub-only — disabled with
// a point-at-the-host tooltip in member windows, enabled on the host AND on a
// standalone instance (standalone is where you create your first member).
// The fleet-surface gate (#1708/#1999 → sister agents). "New agent", "Fleet settings" and the
// ⌘K Fleet Room are live in every window that drives a REAL fleet — the host, a standalone
// instance, and a sister agent's slug window (its fleet calls are hub paths, so they manage the
// hub's fleet). Only a spawned member reached DIRECTLY on its own port is held back, since
// there the fleet genuinely is a fleet-of-one and creating would nest.
import { describe, expect, it } from "vitest";

import type { FleetAgent } from "../lib/types";
Expand All @@ -19,32 +21,39 @@ const agent = (over: Partial<FleetAgent>): FleetAgent => ({
describe("fleetSettingsDisabledReason", () => {
it("host instance with a fleet → enabled", () => {
const agents = [agent({ name: "main", id: "main", host: true }), agent({ name: "ava", id: "ava-7f3a" })];
expect(fleetSettingsDisabledReason(agents, "host")).toBeNull();
expect(fleetSettingsDisabledReason(agents)).toBeNull();
});

it("standalone instance (fleet of one, no member flag) → enabled", () => {
// A standalone /api/fleet still returns its own host entry — this is the window
// where the first fleet member gets CREATED, so it must never be locked out.
const agents = [agent({ name: "main", id: "main", host: true })];
expect(fleetSettingsDisabledReason(agents, "host")).toBeNull();
expect(fleetSettingsDisabledReason(agents)).toBeNull();
});

it("spawned workspace member reached directly (host entry self-reports member) → disabled", () => {
const agents = [agent({ name: "ava", id: "ava-7f3a", host: true, member: true })];
expect(fleetSettingsDisabledReason(agents, "host")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP);
expect(fleetSettingsDisabledReason(agents)).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP);
});

it("member via the hub's slug window → disabled (slug alone decides)", () => {
it("sister agent via the hub's slug window → ENABLED (the roster it sees is the hub's)", () => {
// The window's slug is deliberately not an input: /api/fleet + /api/archetypes are hub
// paths (never slug-scoped), so this window creates into — and manages — the hub's fleet.
// The entry carrying `host` is the HUB's, and a hub never self-reports `member`.
const agents = [agent({ name: "main", id: "main", host: true }), agent({ name: "ava", id: "ava-7f3a" })];
expect(fleetSettingsDisabledReason(agents, "ava-7f3a")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP);
// Even before the fleet poll lands, the slug already marks a member window.
expect(fleetSettingsDisabledReason([], "ava-7f3a")).toBe(FLEET_SETTINGS_MEMBER_TOOLTIP);
expect(fleetSettingsDisabledReason(agents)).toBeNull();
});

it("remote member reached directly at its own URL → enabled (independent instance)", () => {
// A remote's own /api/fleet host entry never carries `member` — registration is
// one-sided on the hub, and the remote may legitimately run its OWN fleet.
const agents = [agent({ name: "peer", id: "peer", host: true })];
expect(fleetSettingsDisabledReason(agents, "host")).toBeNull();
expect(fleetSettingsDisabledReason(agents)).toBeNull();
});

it("an empty roster (the poll hasn't landed) → enabled, so nothing flickers disabled", () => {
// Nothing here proves a fleet-of-one; disabled must never be the cold-open default or the
// menu would flash a point-at-the-host tooltip on every load.
expect(fleetSettingsDisabledReason([])).toBeNull();
});
});
34 changes: 20 additions & 14 deletions apps/web/src/app/fleetSettingsGate.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
// Gate for the header dropdown's "Fleet settings" item (#1708). The fleet is managed
// from its HOST instance only (ADR 0042) — a member window gets the item DISABLED with
// a tooltip pointing at the host instead of a broken/irrelevant panel.
// Gate for the fleet affordances a console window offers: the header dropdown's "New agent"
// + "Fleet settings" items, and the ⌘K Fleet Room. Originally hub-only (#1708/#1999) on the
// theory that any member window manages a fleet-of-one and so could only spawn NESTED agents
// by accident. That holds for exactly one of the four windows below — a spawned member
// reached DIRECTLY on its own port — so the gate asks that question alone, and every sister
// agent gets the same fleet surfaces its hub has.
//
// Host / member / standalone matrix (window = how the console reaches an instance):
//
// host window (hub, or any instance with no slug in the URL and no member flag)
// → ENABLED. Covers both a fleet host and a STANDALONE instance — standalone is
// where you create your first fleet member, so it must never be locked out.
// member via the hub's slug window (/app/agent/<slug>/)
// → DISABLED. The Box ▸ Fleet settings group only exists on the host window
// (ADR 0047 §7.7): opening it here lands on an unrelated fallback section.
// Signal: the URL slug (client-side, no fetch needed).
// → ENABLED. `/api/fleet` and `/api/archetypes` are HUB paths in the console router
// (`isHubPath`, lib/api.ts) — never slug-scoped — so every fleet read and write from
// a sister agent's window already lands on the hub's supervisor: the hub's real
// roster, the hub's fleet to create into, the hub's members to start/stop. Nothing
// nests; the sister manages the same fleet its hub does.
// spawned workspace member reached DIRECTLY (its own port)
// → DISABLED. Its own /api/fleet is a fleet-of-one (its workspaces root is empty
// by construction) — "managing" it there creates a nested fleet by accident.
// → DISABLED. The one true nesting path: its own /api/fleet is a fleet-of-one (its
// workspaces root is empty by construction), so creating there spawns a GRANDCHILD
// the hub's roster never shows, and breaks the invariant that a member's empty
// workspaces root keeps `shutdown_all` hub-only (graph/workspaces/manager.py).
// Signal: the member self-reports `member: true` on its /api/fleet host entry.
// remote member (ADR 0042 §I) reached directly at its own URL
// → ENABLED, deliberately. Registration is one-sided on the hub — the remote has
Expand All @@ -26,13 +33,12 @@ import type { FleetAgent } from "../lib/types";
export const FLEET_SETTINGS_MEMBER_TOOLTIP = "Fleet settings are managed from the host instance";

/**
* Why "Fleet settings" is unavailable in this window — or `null` when it's allowed.
* `agents` is the polled /api/fleet list; `slug` is `currentSlug()` (the window's URL slug,
* `"host"` when the console talks to its instance directly).
* Why the fleet surfaces are unavailable in this window — or `null` when they're allowed.
* `agents` is the polled /api/fleet list. The window's URL slug deliberately does NOT enter
* into it: a sister agent's slug window drives the hub's fleet, exactly as the host console does.
*/
export function fleetSettingsDisabledReason(agents: FleetAgent[], slug: string): string | null {
if (slug !== "host") return FLEET_SETTINGS_MEMBER_TOOLTIP; // hub slug window on a member
export function fleetSettingsDisabledReason(agents: FleetAgent[]): string | null {
const self = agents.find((a) => a.host);
if (self?.member) return FLEET_SETTINGS_MEMBER_TOOLTIP; // spawned member, reached directly
return null; // fleet host, or a standalone instance (no fleet configured yet)
return null; // host, sister-agent slug window, standalone, or a remote's own console
Comment on lines +36 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat an empty roster as unknown, not authorized. The callers use [] before the fleet poll resolves, so enabling the gate at that point can expose “New agent” in a directly reached member window and allow nested fleet creation.

  • apps/web/src/app/fleetSettingsGate.ts#L36-L43: accept roster readiness/loading state and keep controls disabled until the host entry is known.
  • apps/web/src/app/fleetSettingsGate.test.ts#L54-L57: replace the [] => enabled expectation with coverage for the blocked unknown/loading state.
📍 Affects 2 files
  • apps/web/src/app/fleetSettingsGate.ts#L36-L43 (this comment)
  • apps/web/src/app/fleetSettingsGate.test.ts#L54-L57
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/fleetSettingsGate.ts` around lines 36 - 43, Update
fleetSettingsDisabledReason to accept roster readiness/loading state and return
the disabled result until the host entry is known, treating an empty roster as
unknown rather than authorized. In apps/web/src/app/fleetSettingsGate.ts lines
36-43, preserve the existing member and resolved-roster behavior; in
apps/web/src/app/fleetSettingsGate.test.ts lines 54-57, replace the []-enables
expectation with coverage confirming the unknown/loading state remains blocked.

}
Loading
Loading