diff --git a/console/index.html b/console/index.html
index fcc9d62..6b11d2a 100644
--- a/console/index.html
+++ b/console/index.html
@@ -207,6 +207,7 @@
+
diff --git a/console/src/deploy.ts b/console/src/deploy.ts
index f859cec..6e27ebc 100644
--- a/console/src/deploy.ts
+++ b/console/src/deploy.ts
@@ -172,6 +172,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
const acpTokenGenerateBtn = document.getElementById("deploy-acp-token-generate") as HTMLButtonElement | null;
const agentNameInput = document.getElementById("deploy-name") as HTMLInputElement | null;
const agentNameShuffleBtn = document.getElementById("deploy-name-shuffle") as HTMLButtonElement | null;
+ const agentNamePreviewEl = document.getElementById("deploy-name-preview");
const deployBtn = document.getElementById("deploy-deploy-btn") as HTMLButtonElement | null;
const deployStatusEl = document.getElementById("deploy-deploy-status");
@@ -211,6 +212,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
!acpTokenGenerateBtn ||
!agentNameInput ||
!agentNameShuffleBtn ||
+ !agentNamePreviewEl ||
!deployBtn
) {
return null;
@@ -328,6 +330,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
applyChatPlatformMode();
applyVendorMode();
agentNameInput.value = randomGreekName();
+ updateNamePreview();
void loadVendorImage();
};
@@ -346,6 +349,19 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
? k8sNamespaceNewInput.value.trim()
: k8sNamespaceSel.value;
+ // The `oab-${namespace}-${name}` service-name convention (mirrored from the
+ // deploy submit handler below) was previously discoverable only by reading
+ // source — nothing in the wizard showed what actually lands in fleets.toml's
+ // `members` array (Brett, 2026-09-07). "add-instance" into an existing k8s
+ // fleet isn't wired through this wizard yet (see the isK8s check below), so
+ // it's always the ecs/"default" namespace outside "new-fleet" + k8s.
+ const updateNamePreview = (): void => {
+ const isK8s = mode?.kind === "new-fleet" && providerSel.value === "k8s";
+ const namespace = isK8s ? currentNamespace() || "default" : "default";
+ const name = agentNameInput.value.trim() || "
";
+ agentNamePreviewEl.textContent = `→ recorded in fleets.toml as oab-${namespace}-${name}`;
+ };
+
// Toggle the AWS/k8s field groups per studio#104's design: switching
// providers resets which group is visible; field *values* aren't cleared
// here (identityForm.reset() already did that on open/close) since the
@@ -441,6 +457,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
providerSel.addEventListener("change", () => {
showProviderFields(providerSel.value);
if (providerSel.value === "k8s") void loadK8sContexts();
+ updateNamePreview();
});
k8sContextSel.addEventListener("change", () => {
void loadK8sNamespaces();
@@ -449,10 +466,13 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
k8sNamespaceSel.addEventListener("change", () => {
applyNamespaceMode();
void loadK8sServiceAccounts();
+ updateNamePreview();
});
// "change" (fires on commit/blur), not "input" (every keystroke) — avoids a
- // tool call per character typed into the new-namespace field.
+ // tool call per character typed into the new-namespace field. The name
+ // preview updates live regardless (no tool call involved).
k8sNamespaceNewInput.addEventListener("change", () => void loadK8sServiceAccounts());
+ k8sNamespaceNewInput.addEventListener("input", updateNamePreview);
vendorSel.addEventListener("change", () => {
applyVendorMode();
@@ -470,7 +490,9 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
});
agentNameShuffleBtn.addEventListener("click", () => {
agentNameInput.value = randomGreekName();
+ updateNamePreview();
});
+ agentNameInput.addEventListener("input", updateNamePreview);
const open = (m: DeployMode): void => {
mode = m;
diff --git a/console/src/fleetToml.test.ts b/console/src/fleetToml.test.ts
index d6ab4bd..c0c7245 100644
--- a/console/src/fleetToml.test.ts
+++ b/console/src/fleetToml.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { appendMember, appendFleetBlock, fleetBlockExists } from "./fleetToml";
+import { appendMember, appendFleetBlock, fleetBlockExists, removeFleetBlock } from "./fleetToml";
describe("appendMember", () => {
const text = `default_cluster = "oab"
@@ -136,3 +136,47 @@ describe("appendFleetBlock", () => {
expect(out).toBe('default_cluster = "oab"\n\n[fleet.x]\nruntime = "ecs"\nmembers = ["m"]\n');
});
});
+
+describe("removeFleetBlock", () => {
+ const text = `default_cluster = "oab"
+
+[fleet.a]
+members = ["oab-default-a1"]
+
+[fleet.b]
+members = ["oab-default-b1"]
+
+[fleet.c]
+members = ["oab-default-c1"]
+`;
+
+ it("removes a middle block, leaving one blank line between its neighbors", () => {
+ const out = removeFleetBlock(text, "b");
+ expect(out).toBe(
+ 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n\n[fleet.c]\nmembers = ["oab-default-c1"]\n',
+ );
+ });
+
+ it("removes the first block with no leading blank line left behind", () => {
+ const out = removeFleetBlock(text, "a");
+ expect(out).toBe(
+ 'default_cluster = "oab"\n\n[fleet.b]\nmembers = ["oab-default-b1"]\n\n[fleet.c]\nmembers = ["oab-default-c1"]\n',
+ );
+ });
+
+ it("removes the last block with no trailing blank line left behind", () => {
+ const out = removeFleetBlock(text, "c");
+ expect(out).toBe(
+ 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n\n[fleet.b]\nmembers = ["oab-default-b1"]\n',
+ );
+ });
+
+ it("removes the only fleet block, leaving the rest of the file intact", () => {
+ const onlyOne = 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n';
+ expect(removeFleetBlock(onlyOne, "a")).toBe('default_cluster = "oab"\n');
+ });
+
+ it("is a no-op when the fleet isn't found", () => {
+ expect(removeFleetBlock(text, "no-such-fleet")).toBe(text);
+ });
+});
diff --git a/console/src/fleetToml.ts b/console/src/fleetToml.ts
index 10382f5..489aff2 100644
--- a/console/src/fleetToml.ts
+++ b/console/src/fleetToml.ts
@@ -71,6 +71,23 @@ export function appendMember(text: string, fleetName: string, member: string): s
return text.slice(0, block.headerEnd) + newBody + text.slice(block.end);
}
+// Remove a `[fleet.]` block entirely — the inverse of `appendFleetBlock`
+// (Fleet detail's "Delete fleet" action: the console had no way to remove a
+// fleet short of hand-editing raw TOML via "Edit config"). `findFleetBlock`'s
+// `end` already lands exactly at the next block's `[` (or EOF), so the blank
+// line `appendFleetBlock` writes *before* each block travels with `before`,
+// not `after` — a plain concatenation needs no separator patching. The
+// `\n{3,}` collapse and single trailing newline are defensive tidy-ups, not
+// load-bearing (see fleetToml.test.ts for why each is safe). A no-op if the
+// fleet isn't found.
+export function removeFleetBlock(text: string, name: string): string {
+ const block = findFleetBlock(text, name);
+ if (!block) return text;
+ const result = text.slice(0, block.start) + text.slice(block.end);
+ const collapsed = result.replace(/\n{3,}/g, "\n\n");
+ return collapsed.trim() ? collapsed.replace(/\n+$/, "\n") : "";
+}
+
export interface NewFleetEntry {
name: string;
member: string;
diff --git a/console/src/main.ts b/console/src/main.ts
index 4b8cb86..3188c34 100644
--- a/console/src/main.ts
+++ b/console/src/main.ts
@@ -1,5 +1,6 @@
import { defaultSource } from "./source";
import { initConfigTab } from "./config";
+import { removeFleetBlock } from "./fleetToml";
import {
renderRoster,
renderFleetConfig,
@@ -487,6 +488,31 @@ function deselectFleet(): void {
void tick();
}
+// Fleet detail's "Delete fleet" button: the only way to remove a fleet used
+// to be hand-editing raw TOML via "Edit config" (Brett, 2026-09-07). This is
+// the same text edit (`removeFleetBlock`), just behind a confirm dialog —
+// removes the `[fleet.]` declaration only, it does not touch the
+// underlying deployments/instances themselves.
+async function deleteFleet(name: string): Promise {
+ if (
+ !window.confirm(
+ `Delete fleet "${name}"? This removes it from fleets.toml — the instances themselves keep running.`,
+ )
+ ) {
+ return;
+ }
+ try {
+ const current = fleetConfig ?? (await source.fleetConfig());
+ fleetConfig = await source.writeFleetConfig(removeFleetBlock(current.text, name));
+ note("info", `config: deleted fleet "${name}"`);
+ } catch (e) {
+ note("error", `config: delete fleet failed — ${errText(e)}`);
+ return;
+ }
+ if (activeFleet === name) deselectFleet();
+ else if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet);
+}
+
// After a deploy panel run succeeds (deploy_provision + fleets.toml write both
// landed — `deploy.ts` guarantees that ordering): re-read fleets.toml, then
// either land on the new fleet's detail screen (7.5.1 step 4) or, if we're
@@ -681,11 +707,6 @@ if (configEl) {
deployPanel?.open({ kind: "new-fleet" });
return;
}
- const debugBtn = target.closest('[data-action="fleet-debug"]');
- if (debugBtn) {
- openDebugDrawer(debugBtn.dataset.fleet ?? "");
- return;
- }
const btn = target.closest("[data-fleet]");
if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet);
});
@@ -707,6 +728,10 @@ if (fleetDetailEl) {
}
if (target.closest('[data-action="fleet-debug"]') && activeFleet) {
openDebugDrawer(activeFleet);
+ return;
+ }
+ if (target.closest('[data-action="delete-fleet"]') && activeFleet) {
+ void deleteFleet(activeFleet);
}
});
}
diff --git a/console/src/render.test.ts b/console/src/render.test.ts
index 0f46ff6..6ec8796 100644
--- a/console/src/render.test.ts
+++ b/console/src/render.test.ts
@@ -234,12 +234,9 @@ describe("fleetConfigHtml", () => {
).toContain('data-action="new-fleet"');
});
- it("gives each fleet row its own Debug-drawer gear, scoped by fleet name (ADR #83 slice 6, 7.2)", () => {
+ it("has no per-card Debug gear on the Fleets list (removed 2026-09-07 — Fleet detail's own [⚙] covers it)", () => {
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca");
- const gears = html.match(/data-action="fleet-debug"/g) ?? [];
- expect(gears.length).toBe(FIXTURE_FLEET_CONFIG.fleets.length);
- expect(html).toContain('data-action="fleet-debug" data-fleet="orca"');
- expect(html).toContain('data-action="fleet-debug" data-fleet="mira"');
+ expect(html).not.toContain('data-action="fleet-debug"');
});
it("renders an unavailable state for null", () => {
@@ -270,6 +267,11 @@ describe("fleetDetailHeaderHtml", () => {
expect(html).not.toContain('data-action="fleet-debug" disabled');
});
+ it("wires the Delete fleet action", () => {
+ const html = fleetDetailHeaderHtml("oab-prod-orca");
+ expect(html).toContain('data-action="delete-fleet"');
+ });
+
it("escapes the fleet name", () => {
const html = fleetDetailHeaderHtml("");
expect(html).toContain("<x>");
diff --git a/console/src/render.ts b/console/src/render.ts
index ef92153..203e153 100644
--- a/console/src/render.ts
+++ b/console/src/render.ts
@@ -170,11 +170,11 @@ function membersLine(f: FleetConfig["fleets"][number]): string {
return `${chips}`;
}
-// The `[⚙]` sits beside, not inside, the switch button — a fleet row is two
-// independent click targets (select vs. debug), not one giant button, so
-// they're siblings under a `.fleets-row` wrapper rather than nested
-// ``;
}
// Pure: the fleet-binding config -> the config panel HTML. A fleet is a
@@ -237,7 +234,10 @@ export function renderFleetConfig(
// "← Fleets" returns to the Fleets screen (Part A's drill-down). `[+ Add
// instance]` is the slice 5 entry point (7.5.2: deploy into this fleet, no new
// fleet-identity step). `[⚙]` is the slice 6 entry point — opens the Debug
-// drawer (Activity/MCP/Config) scoped to this fleet.
+// drawer (Activity/MCP/Config) scoped to this fleet. `[Delete fleet]` is a
+// follow-up (Brett, 2026-09-07): the only way to remove a fleet before this
+// was hand-editing raw TOML via "Edit config" — this just does that same
+// text edit (`removeFleetBlock`) behind a button + confirm, in `main.ts`.
export function fleetDetailHeaderHtml(fleetName: string): string {
return `
← Fleets
@@ -246,6 +246,7 @@ export function fleetDetailHeaderHtml(fleetName: string): string {
+ Add instance
⚙
+ Delete fleet
`;
}
diff --git a/console/src/styles.css b/console/src/styles.css
index a3bfb55..4d43f07 100644
--- a/console/src/styles.css
+++ b/console/src/styles.css
@@ -712,14 +712,6 @@ button.act:disabled {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
-.fleets-row {
- display: flex;
- align-items: stretch;
- gap: 6px;
-}
-.fleets-row .fd-gear {
- flex: 0 0 auto;
-}
.cfg-fleet {
display: flex;
flex: 1;
@@ -830,6 +822,10 @@ button.act:disabled {
.fd-gear {
padding: 3px 8px;
}
+.fd-danger:hover {
+ color: var(--s-unhealthy);
+ border-color: var(--s-unhealthy);
+}
.fd-sep {
color: var(--muted);
}