From 47a3e8a3cd8012a999b315f5059f9b6a55b11b96 Mon Sep 17 00:00:00 2001 From: "Orca (ecs-claude)" Date: Wed, 19 Aug 2026 09:45:25 +0800 Subject: [PATCH 1/5] fix(console): give .content a horizontal gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .content has been display:flex/flex-direction:column with no padding since the very first commit β€” every top-level section only carries a bottom margin, so nothing ever set a left/right gutter and content ran edge-to-edge. Add padding matching the topbar's horizontal inset (18px). πŸ€– Generated by Orca ('ecs-claude'). --- console/src/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/console/src/styles.css b/console/src/styles.css index 3dccf77..7912b84 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -179,6 +179,7 @@ body { .content { display: flex; flex-direction: column; + padding: 16px 18px; } /* ---- Drill-down row: Fleets/Fleet-detail/Agent-console + persistent chat From a2e74f7466f6958f8306e1196f3d631901e7cfad Mon Sep 17 00:00:00 2001 From: "Orca (ecs-claude)" Date: Wed, 19 Aug 2026 09:50:40 +0800 Subject: [PATCH 2/5] feat(console): move Managing banner above Agent chat, resizable + bigger chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brett flagged three related issues live on #91's layout: - the "MANAGING " identity banner should sit above Agent chat in the right column, not as a full-width strip below the drilldown row - Agent chat's fixed 120–340px log box reads as too small - the two-column split (drill-down content vs. chat) should be adjustable ## What - `#identity` moves into a new `.drilldown-side` wrapper alongside `#chat`, right column of the drilldown row (was a standalone full-width section). - `.drilldown-side > .chat-wrap .chat-log` bumps to a 240–560px range. - A drag handle (`#drilldown-resizer`) between the two columns lets the operator widen/narrow `.drilldown-side` (280–900px), width persisted to localStorage. `src/splitPane.ts` owns the pure clamp/persist logic (`clampWidth`/`readWidth`/`saveWidth`) plus the DOM wiring (`initSplitPane`), mirroring `theme.ts`'s split. ## Verification - `tsc --noEmit` β€” clean - `vitest run` β€” 112/112 passing (5 new for `splitPane.ts`) - `vite build` β€” clean - Playwright: confirmed identity renders above chat inside `.drilldown-side`, and dragging the handle grows the column live (420px β†’ 626px in the test drag) β€” screenshots eyeballed for visual regressions. πŸ€– Generated by Orca ('ecs-claude'). --- console/index.html | 31 +++++++++-------- console/src/main.ts | 7 ++++ console/src/splitPane.test.ts | 26 +++++++++++++++ console/src/splitPane.ts | 63 +++++++++++++++++++++++++++++++++++ console/src/styles.css | 55 +++++++++++++++++++++++++++--- 5 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 console/src/splitPane.test.ts create mode 100644 console/src/splitPane.ts diff --git a/console/index.html b/console/index.html index 7b71010..4fe7157 100644 --- a/console/index.html +++ b/console/index.html @@ -83,20 +83,24 @@ -
-
- Agent chat - disconnected -
-
-
- -
- - +
+
+
+
+
+ Agent chat + disconnected
- -
+
+
+ +
+ + +
+
+
+ -
+
Compose template βŠ• overlay authoring β€” golden bundle library diff --git a/console/src/agentConsole.ts b/console/src/agentConsole.ts index 09350e8..051763a 100644 --- a/console/src/agentConsole.ts +++ b/console/src/agentConsole.ts @@ -7,13 +7,12 @@ // It stays out of the transcript/turn machinery β€” that is `chatPanel.ts`. Its // job is selection, the dial/teardown, the read-only config header, and // registering the mounted panel in the shared event-router map so `main.ts` can -// route this agent's `agent-update` / `remote-status` events to it. The remote -// file editor (view/edit/apply) is a later slice; config is read-only here. +// route this agent's `agent-update` / `remote-status` events to it. Config is +// read-only here. import type { Source } from "./source"; import type { AgentEndpointView } from "./types"; import { createChatPanel, type ChatPanel } from "./chatPanel"; -import { createFileBrowser, type FileBrowser } from "./fileBrowser"; import { renderAgentList, agentConsoleHeaderHtml } from "./render"; export interface AgentConsoleConfig { @@ -47,9 +46,6 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { const send = document.getElementById("ac-chat-send") as HTMLButtonElement | null; const stop = document.getElementById("ac-chat-stop") as HTMLButtonElement | null; const conn = document.getElementById("ac-chat-conn"); - const fbList = document.getElementById("ac-files-list"); - const fbViewer = document.getElementById("ac-files-viewer"); - const fbTitle = document.getElementById("ac-files-title"); const noop: AgentConsole = { refresh: async () => {}, @@ -61,7 +57,6 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { let agents: AgentEndpointView[] = []; let openName: string | null = null; let panel: ChatPanel | null = null; - let fileBrowser: FileBrowser | null = null; const ac = new AbortController(); const { signal } = ac; @@ -103,8 +98,6 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { openName = null; panel?.dispose(); panel = null; - fileBrowser?.dispose(); - fileBrowser = null; cfg.panels.delete(name); if (consoleEl) consoleEl.hidden = true; // Fire-and-forget teardown; a failed disconnect is logged, not fatal. @@ -143,15 +136,6 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { ); cfg.panels.set(name, panel); } - // Mount the read-only file browser for this agent (Part D). It probes fs - // capability itself and shows a "pending the fs MCP files server" placeholder - // when the endpoint has no fs support β€” which is every real endpoint today. - if (fbList && fbViewer && fbTitle) { - fileBrowser = createFileBrowser( - { list: fbList, viewer: fbViewer, title: fbTitle }, - { agent: name, source: cfg.source, note: cfg.note }, - ); - } try { await cfg.source.remoteConnect(name); cfg.note("info", `agents: opened console for "${name}" β€” dialing ${a.url}`); diff --git a/console/src/fileBrowser.ts b/console/src/fileBrowser.ts deleted file mode 100644 index fff7804..0000000 --- a/console/src/fileBrowser.ts +++ /dev/null @@ -1,164 +0,0 @@ -// The remote file editor's **read** path (ADR agent-consoles Part D): a -// directory browser over an agent's filesystem + a read-only viewer, mounted in -// an open agent console. It is capability-gated β€” fs is an MCP files server the -// target agent exposes (reached Studio-brokered via the `oab` relay), and that -// server does not exist yet, so on a real endpoint `fsCapability` reports -// unsupported and this renders a "pending the fs MCP files server" placeholder. -// The browser build's mock source serves a fixture filesystem so the surface is -// still demonstrable. -// -// The listing HTML is pure (`render.ts`, unit-tested); this owns the imperative -// shell: the fetch/navigate lifecycle, the delegated click handler, and the -// read-only CodeMirror viewer. The **write** path (Apply) is slice 4. - -import { EditorView, basicSetup } from "codemirror"; -import { EditorState, type Extension } from "@codemirror/state"; -import { StreamLanguage } from "@codemirror/language"; -import { toml } from "@codemirror/legacy-modes/mode/toml"; -import type { Source } from "./source"; -import { fsListingHtml, fsUnavailableHtml } from "./render"; - -export interface FileBrowserElements { - // The listing container (directory rows). - list: HTMLElement; - // The read-only CodeMirror mount. - viewer: HTMLElement; - // The open-file path / status line. - title: HTMLElement; -} - -export interface FileBrowserOptions { - // The registry endpoint name whose filesystem is browsed. - agent: string; - source: Source; - note: (level: "info" | "error", msg: string) => void; -} - -export interface FileBrowser { - dispose(): void; -} - -const UNAVAILABLE = "Remote file editor unavailable β€” pending the fs MCP files server."; - -function errText(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -function dirname(path: string): string { - const cut = path.replace(/\/+$/, "").replace(/\/[^/]+$/, ""); - return cut === "" ? "/" : cut; -} - -export function createFileBrowser( - els: FileBrowserElements, - opts: FileBrowserOptions, -): FileBrowser { - let roots: string[] = []; - let cwd = ""; - let selectedPath: string | null = null; - let view: EditorView | null = null; - const ac = new AbortController(); - const { signal } = ac; - - function destroyViewer(): void { - view?.destroy(); - view = null; - } - - // Show a file's text in a fresh read-only editor. `.toml` gets TOML highlighting - // (the mode already bundled for the config editor); everything else is plain. - function showFile(path: string, text: string, truncated: boolean): void { - destroyViewer(); - const ext: Extension[] = [ - basicSetup, - EditorState.readOnly.of(true), - EditorView.editable.of(false), - ]; - if (path.endsWith(".toml")) ext.push(StreamLanguage.define(toml)); - view = new EditorView({ - parent: els.viewer, - state: EditorState.create({ doc: text, extensions: ext }), - }); - els.title.textContent = truncated ? `${path} Β· truncated` : path; - } - - // The "up one level" affordance shows while we're below an editable root. - function canGoUp(): boolean { - return !roots.includes(cwd) && cwd !== "/" && cwd !== ""; - } - - function renderList(listing: Parameters[0]): void { - els.list.innerHTML = fsListingHtml(listing, { - selectedPath, - canGoUp: canGoUp(), - }); - } - - async function loadDir(path: string): Promise { - try { - const listing = await opts.source.fsList(path, opts.agent); - cwd = listing.path || path; - renderList(listing); - } catch (e) { - els.list.innerHTML = fsUnavailableHtml(`cannot list ${path} β€” ${errText(e)}`); - } - } - - async function openFile(path: string): Promise { - try { - const file = await opts.source.fsRead(path, opts.agent); - selectedPath = file.path || path; - showFile(selectedPath, file.text, file.truncated); - // Re-render the current listing so the open row is marked. - await loadDir(cwd); - } catch (e) { - opts.note("error", `files: read ${path} failed β€” ${errText(e)}`); - els.title.textContent = `${path} Β· read failed`; - } - } - - async function init(): Promise { - els.title.textContent = "files"; - let cap; - try { - cap = await opts.source.fsCapability(opts.agent); - } catch (e) { - els.list.innerHTML = fsUnavailableHtml(`fs capability check failed β€” ${errText(e)}`); - return; - } - if (!cap.supported) { - els.list.innerHTML = fsUnavailableHtml(UNAVAILABLE); - return; - } - roots = cap.roots.length ? cap.roots : ["/"]; - await loadDir(roots[0]); - } - - els.list.addEventListener( - "click", - (ev) => { - const t = ev.target as HTMLElement; - const dir = t.closest("[data-fs-dir]"); - if (dir?.dataset.fsDir) { - void loadDir(dir.dataset.fsDir); - return; - } - const file = t.closest("[data-fs-file]"); - if (file?.dataset.fsFile) { - void openFile(file.dataset.fsFile); - return; - } - if (t.closest("[data-fs-up]")) void loadDir(dirname(cwd)); - }, - { signal }, - ); - - void init(); - - return { - dispose: () => { - destroyViewer(); - ac.abort(); - }, - }; -} diff --git a/console/src/main.ts b/console/src/main.ts index 5cfc20f..dc9ec45 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -52,6 +52,7 @@ const configEl = document.getElementById("config"); const fleetDetailEl = document.getElementById("fleet-detail"); const fdHeaderEl = document.getElementById("fd-header"); const remoteEl = document.getElementById("remote"); +const composeStandaloneEl = document.getElementById("compose-standalone"); const editorSection = document.getElementById("config-editor"); const editorMount = document.getElementById("cfg-editor-mount"); const editorError = document.getElementById("cfg-editor-error"); @@ -297,9 +298,14 @@ async function refreshRemote(): Promise { // `activeFleet === null` shows the Fleets screen (the `#config` list); a // selected fleet shows Fleet detail (breadcrumb header + the members roster, // with each member drilling further into its Agent console β€” slice 3) instead. +// Remote (management-connection setup) and Compose (template/bundle authoring) +// are Fleets-screen concerns β€” not relevant once drilled into a fleet/agent, so +// they hide alongside it. function updateScreen(): void { if (configEl) configEl.hidden = activeFleet !== null; if (fleetDetailEl) fleetDetailEl.hidden = activeFleet === null; + if (remoteEl) remoteEl.hidden = activeFleet !== null; + if (composeStandaloneEl) composeStandaloneEl.hidden = activeFleet !== null; if (activeFleet && fdHeaderEl) renderFleetDetailHeader(fdHeaderEl, activeFleet); } @@ -920,6 +926,20 @@ async function boot(): Promise { const drilldownResizer = document.getElementById("drilldown-resizer"); const drilldownSide = document.getElementById("drilldown-side"); if (drilldownResizer && drilldownSide) initSplitPane(drilldownResizer, drilldownSide); + // The Debug drawer anchors below the topbar (`--topbar-h`, styles.css) so it + // slides in without hiding the topbar or the drawer's own header underneath + // it. Tracked live β€” the topbar's height isn't hard-coded. + const topbarEl = document.querySelector(".topbar"); + if (topbarEl) { + const syncTopbarHeight = (): void => { + document.documentElement.style.setProperty( + "--topbar-h", + `${topbarEl.getBoundingClientRect().height}px`, + ); + }; + syncTopbarHeight(); + new ResizeObserver(syncTopbarHeight).observe(topbarEl); + } setupUpdater(); await startCore(); // Debug drawer's Config tab: pin the oab-mcp target (cluster/profile/region β†’ diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 96b5a10..bd1cf7f 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -7,8 +7,6 @@ import { remoteHtml, agentListHtml, agentConsoleHeaderHtml, - fsListingHtml, - fsUnavailableHtml, filterByMembers, serviceName, deploymentKey, @@ -24,7 +22,6 @@ import { AGENT_STATES, type AgentEndpointView, type Deployment, - type FsListing, type RuntimeContext, } from "./types"; @@ -525,75 +522,4 @@ describe("agentConsoleHeaderHtml", () => { const html = agentConsoleHeaderHtml(orca, "connected"); expect(html.toLowerCase()).not.toContain("token"); }); - - it("notes the read-only editor limitation until the fs MCP files server lands", () => { - expect(agentConsoleHeaderHtml(orca, "disconnected")).toContain("Read-only"); - }); -}); - -describe("fsListingHtml", () => { - const listing: FsListing = { - path: "/home/node", - entries: [ - { name: "notes.md", path: "/home/node/notes.md", kind: "file", size: 140 }, - { name: "agent_profiling", path: "/home/node/agent_profiling", kind: "dir" }, - { name: "CLAUDE.md", path: "/home/node/CLAUDE.md", kind: "file", size: 2048 }, - ], - }; - - it("sorts directories before files, each alphabetically", () => { - const html = fsListingHtml(listing); - const iDir = html.indexOf("agent_profiling"); - const iClaude = html.indexOf("CLAUDE.md"); - const iNotes = html.indexOf("notes.md"); - expect(iDir).toBeLessThan(iClaude); // dir before any file - expect(iClaude).toBeLessThan(iNotes); // files alphabetical - }); - - it("hooks dirs and files with the right navigation attributes", () => { - const html = fsListingHtml(listing); - expect(html).toContain('data-fs-dir="/home/node/agent_profiling"'); - expect(html).toContain('data-fs-file="/home/node/CLAUDE.md"'); - }); - - it("shows a human-readable size for files only", () => { - const html = fsListingHtml(listing); - expect(html).toContain("2.0 KB"); // CLAUDE.md - expect(html).toContain("140 B"); // notes.md - }); - - it("renders the breadcrumb path", () => { - expect(fsListingHtml(listing)).toContain("/home/node"); - }); - - it("marks the open file", () => { - const html = fsListingHtml(listing, { selectedPath: "/home/node/CLAUDE.md" }); - expect(html).toMatch(/is-open[^>]*data-fs-file="\/home\/node\/CLAUDE\.md"/); - }); - - it("shows an up affordance only when canGoUp", () => { - expect(fsListingHtml(listing, { canGoUp: true })).toContain("data-fs-up"); - expect(fsListingHtml(listing, { canGoUp: false })).not.toContain("data-fs-up"); - }); - - it("renders an empty-directory note when there are no entries and no up", () => { - expect(fsListingHtml({ path: "/x", entries: [] })).toContain("empty directory"); - }); - - it("escapes entry names and paths", () => { - const html = fsListingHtml({ - path: "/x", - entries: [{ name: "