diff --git a/README.md b/README.md index c39f401..d7f89c2 100644 --- a/README.md +++ b/README.md @@ -245,12 +245,16 @@ agent-box-session rm review # delist + kill The site root (web setups) is the terminal itself — `https:///` is a tabbed workspace, one tab per session, behind the same login as the -terminal (add sessions from the tab bar; restart/delete on the settings -page) — and agents can spawn sibling sessions themselves (it's just a file -edit on their own account — handy for "have Codex cross-check this"). +terminal (add and close sessions from the tab bar — the tab's `×` arms on +the first click and only closes on the second; restart/delete also on the +settings page) — and agents can spawn sibling sessions themselves (it's just +a file edit on their own account — handy for "have Codex cross-check this"). ![Tabbed terminal workspace: one tab per session](docs/workspace-tabs.png) +![Closing a session from its tab: the × arms on the first click and only +closes on the second](docs/workspace-tab-close.png) + Attach locally with `tmux -L agent-box attach -t ` (see `TMUX_TMPDIR` note above). In the browser, every tab is also a deep-linkable standalone terminal at diff --git a/docs/workspace-tab-close.png b/docs/workspace-tab-close.png new file mode 100644 index 0000000..e1e0d30 Binary files /dev/null and b/docs/workspace-tab-close.png differ diff --git a/modules/agent-box.nix b/modules/agent-box.nix index f1701d2..6d4abb1 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -3075,13 +3075,41 @@ in .tabs { display: flex; align-items: flex-end; gap: 2px; flex: none; padding: 8px 8px 0; background: #010409; border-bottom: 1px solid #30363d; overflow-x: auto; } - .tab { display: inline-flex; align-items: center; padding: 6px 14px 8px; + .tab { display: inline-flex; align-items: center; padding: 6px 32px 8px 14px; font-size: 13px; color: #8b949e; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; white-space: nowrap; } .tab:hover { color: #e6edf3; } .tab[aria-current] { background: #0d1117; border-color: #30363d; color: #e6edf3; } + /* Close (x) button, absolutely placed over the tab's extra right + padding so it reads as part of the tab while staying a DOM sibling + of the link (see render_tabs). Dim until hovered — always visible + rather than hover-only, because touch has no hover. .arm is the + armed state SCRIPT sets on the first click: a red "Close?" pill + growing leftwards over the name, so the second click is clearly a + confirmation and never mistaken for a plain tab switch. */ + .tab-wrap { position: relative; display: flex; } + .tab-wrap:hover .tab { color: #e6edf3; } + /* The armed tab tints too: the pill necessarily covers the tail of a + short name, so the tab itself has to say which session is at stake. + Only the (already 1px, transparent) border changes colour, so + nothing moves under the cursor between the two clicks. */ + .tab-wrap.arm .tab { background: rgba(248,81,73,.12); border-color: #f85149; + color: #e6edf3; } + .tab-close { margin: 0; } + .tab-x { position: absolute; top: 6px; right: 6px; bottom: 8px; width: 18px; + display: flex; align-items: center; justify-content: center; + padding: 0; border: 0; border-radius: 5px; background: transparent; + font: inherit; font-size: 15px; line-height: 1; color: #6e7681; + cursor: pointer; } + .tab-x:hover { background: #30363d; color: #e6edf3; } + .tab-x.arm { width: auto; padding: 0 7px; font-size: 11px; font-weight: 600; + background: #da3633; color: #fff; + /* Fades the name out under the pill, so the overlap reads + as deliberate rather than as clipped text. */ + box-shadow: -7px 0 9px 3px rgba(9,12,17,.92); } + .tab-x.arm:hover { background: #f85149; } .tab-empty { color: #8b949e; font-size: 13px; padding: 6px 8px 8px; } .tabs .btn.add { margin: 0 4px 6px; padding: 2px 9px; } .tabs .spacer { flex: 1; } @@ -3619,6 +3647,79 @@ in wsSelect(t.getAttribute("data-tab"), true); }); + // Two-click close on the tab bar's x. Closing kills a live agent and + // the button sits a few pixels from the session name, so the first + // click only ARMS it (red "Close?" pill, see .tab-x.arm) and the + // second lets the form submit for real — a confirm() dialog would be + // both heavier and skipped entirely when scripting is off, whereas + // here no-JS simply keeps the plain one-click POST. + // + // Anything that could leave a loaded gun on screen disarms: a + // timeout, Escape, tabbing away, arming another tab, or any click + // elsewhere. A tab-bar re-render (polling swaps #tab-bar wholesale) + // detaches the button, which the isConnected check absorbs. + var ARM_MS = 4000; // long enough to read the pill and aim at it + var SETTLE_MS = 350; // a double-click is not a considered confirmation + var armedX = null; + var armedAt = 0; + var armTimer = null; + function wrapOf(b) { return b.closest ? b.closest(".tab-wrap") : null; } + function clearArm() { + if (armTimer) { window.clearTimeout(armTimer); armTimer = null; } + var b = armedX; + armedX = null; + return b; + } + function disarmX() { + var b = clearArm(); + if (!b || !b.isConnected) { return; } + var label = "Close " + b.getAttribute("data-close"); + var w = wrapOf(b); + if (w) { w.classList.remove("arm"); } + b.classList.remove("arm"); + b.textContent = "×"; + b.setAttribute("aria-label", label); + b.setAttribute("title", label); + } + function armX(b) { + disarmX(); + var hint = "Click again to close " + b.getAttribute("data-close"); + var w = wrapOf(b); + armedX = b; + armedAt = Date.now(); + if (w) { w.classList.add("arm"); } + b.classList.add("arm"); + b.textContent = "Close?"; + b.setAttribute("aria-label", hint); + b.setAttribute("title", hint); + armTimer = window.setTimeout(disarmX, ARM_MS); + } + document.addEventListener("click", function (e) { + var x = e.target && e.target.closest ? e.target.closest("#tab-bar .tab-x") : null; + // Second click on the armed button: fall through with no + // preventDefault so the form submits (the submit handler below + // upgrades it to fetch + patch, like every other form here). Only + // the timer is dropped — the pill stays red until the patched tab + // bar comes back, so the click has visible effect in flight. + // Too soon after arming it is the tail of a double-click, not a + // decision: swallow it and stay armed. + if (x && x === armedX) { + if (Date.now() - armedAt < SETTLE_MS) { e.preventDefault(); return; } + clearArm(); + return; + } + disarmX(); + if (!x) { return; } + e.preventDefault(); + armX(x); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { disarmX(); } + }); + document.addEventListener("focusout", function (e) { + if (armedX && e.target === armedX) { disarmX(); } + }); + // The editors render expanded (no-JS fallback); collapse them once // JS is live so the page opens in list-only, GitHub-style form. ["secret-editor", "session-editor", "password-editor"].forEach(function (id) { @@ -3973,15 +4074,31 @@ in """The workspace tab bar. File order, not sorted: sessions.json preserves insertion order, so a new session appears as the rightmost tab, like any terminal app. The dot-only .state span - reuses the list styling (its ::before is the dot).""" + reuses the list styling (its ::before is the dot). + + Each tab carries a close (x) button posting to the same + /sessions/delete route the settings page uses (no back= field, so + it redirects to the workspace). The button is a SIBLING of the tab + link, not a child: a
inside an is invalid markup, and + keeping them apart also stops the tab-select click delegation from + swallowing the close click. Closing kills a live agent and the + button sits a few pixels from the session name, so SCRIPT arms it + on the first click and only submits on the second.""" items = [] + base = html.escape(SESS_BASE) for name in names: safe = html.escape(name) cur = ' aria-current="page"' if name == selected else "" state = "live" if name in live else "starting" items.append( + f'' f'' f'{safe}' + f'' + f'' + f'' + f'
' ) if not items: items.append('No sessions yet.') diff --git a/modules/src/settings-daemon.py b/modules/src/settings-daemon.py index 126c4d7..d664f8d 100644 --- a/modules/src/settings-daemon.py +++ b/modules/src/settings-daemon.py @@ -896,15 +896,31 @@ def render_tabs(names, live, selected): """The workspace tab bar. File order, not sorted: sessions.json preserves insertion order, so a new session appears as the rightmost tab, like any terminal app. The dot-only .state span - reuses the list styling (its ::before is the dot).""" + reuses the list styling (its ::before is the dot). + + Each tab carries a close (x) button posting to the same + /sessions/delete route the settings page uses (no back= field, so + it redirects to the workspace). The button is a SIBLING of the tab + link, not a child: a
inside an is invalid markup, and + keeping them apart also stops the tab-select click delegation from + swallowing the close click. Closing kills a live agent and the + button sits a few pixels from the session name, so SCRIPT arms it + on the first click and only submits on the second.""" items = [] + base = html.escape(SESS_BASE) for name in names: safe = html.escape(name) cur = ' aria-current="page"' if name == selected else "" state = "live" if name in live else "starting" items.append( + f'' f'' f'{safe}' + f'' + f'' + f'' + f'
' ) if not items: items.append('No sessions yet.') diff --git a/modules/src/settings.css b/modules/src/settings.css index c27af08..f85cbb8 100644 --- a/modules/src/settings.css +++ b/modules/src/settings.css @@ -101,13 +101,41 @@ body.ws { display: flex; flex-direction: column; height: 100vh; .tabs { display: flex; align-items: flex-end; gap: 2px; flex: none; padding: 8px 8px 0; background: #010409; border-bottom: 1px solid #30363d; overflow-x: auto; } -.tab { display: inline-flex; align-items: center; padding: 6px 14px 8px; +.tab { display: inline-flex; align-items: center; padding: 6px 32px 8px 14px; font-size: 13px; color: #8b949e; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; white-space: nowrap; } .tab:hover { color: #e6edf3; } .tab[aria-current] { background: #0d1117; border-color: #30363d; color: #e6edf3; } +/* Close (x) button, absolutely placed over the tab's extra right + padding so it reads as part of the tab while staying a DOM sibling + of the link (see render_tabs). Dim until hovered — always visible + rather than hover-only, because touch has no hover. .arm is the + armed state SCRIPT sets on the first click: a red "Close?" pill + growing leftwards over the name, so the second click is clearly a + confirmation and never mistaken for a plain tab switch. */ +.tab-wrap { position: relative; display: flex; } +.tab-wrap:hover .tab { color: #e6edf3; } +/* The armed tab tints too: the pill necessarily covers the tail of a + short name, so the tab itself has to say which session is at stake. + Only the (already 1px, transparent) border changes colour, so + nothing moves under the cursor between the two clicks. */ +.tab-wrap.arm .tab { background: rgba(248,81,73,.12); border-color: #f85149; + color: #e6edf3; } +.tab-close { margin: 0; } +.tab-x { position: absolute; top: 6px; right: 6px; bottom: 8px; width: 18px; + display: flex; align-items: center; justify-content: center; + padding: 0; border: 0; border-radius: 5px; background: transparent; + font: inherit; font-size: 15px; line-height: 1; color: #6e7681; + cursor: pointer; } +.tab-x:hover { background: #30363d; color: #e6edf3; } +.tab-x.arm { width: auto; padding: 0 7px; font-size: 11px; font-weight: 600; + background: #da3633; color: #fff; + /* Fades the name out under the pill, so the overlap reads + as deliberate rather than as clipped text. */ + box-shadow: -7px 0 9px 3px rgba(9,12,17,.92); } +.tab-x.arm:hover { background: #f85149; } .tab-empty { color: #8b949e; font-size: 13px; padding: 6px 8px 8px; } .tabs .btn.add { margin: 0 4px 6px; padding: 2px 9px; } .tabs .spacer { flex: 1; } diff --git a/modules/src/settings.js b/modules/src/settings.js index 08e75c3..2d091ae 100644 --- a/modules/src/settings.js +++ b/modules/src/settings.js @@ -290,6 +290,79 @@ wsSelect(t.getAttribute("data-tab"), true); }); + // Two-click close on the tab bar's x. Closing kills a live agent and + // the button sits a few pixels from the session name, so the first + // click only ARMS it (red "Close?" pill, see .tab-x.arm) and the + // second lets the form submit for real — a confirm() dialog would be + // both heavier and skipped entirely when scripting is off, whereas + // here no-JS simply keeps the plain one-click POST. + // + // Anything that could leave a loaded gun on screen disarms: a + // timeout, Escape, tabbing away, arming another tab, or any click + // elsewhere. A tab-bar re-render (polling swaps #tab-bar wholesale) + // detaches the button, which the isConnected check absorbs. + var ARM_MS = 4000; // long enough to read the pill and aim at it + var SETTLE_MS = 350; // a double-click is not a considered confirmation + var armedX = null; + var armedAt = 0; + var armTimer = null; + function wrapOf(b) { return b.closest ? b.closest(".tab-wrap") : null; } + function clearArm() { + if (armTimer) { window.clearTimeout(armTimer); armTimer = null; } + var b = armedX; + armedX = null; + return b; + } + function disarmX() { + var b = clearArm(); + if (!b || !b.isConnected) { return; } + var label = "Close " + b.getAttribute("data-close"); + var w = wrapOf(b); + if (w) { w.classList.remove("arm"); } + b.classList.remove("arm"); + b.textContent = "×"; + b.setAttribute("aria-label", label); + b.setAttribute("title", label); + } + function armX(b) { + disarmX(); + var hint = "Click again to close " + b.getAttribute("data-close"); + var w = wrapOf(b); + armedX = b; + armedAt = Date.now(); + if (w) { w.classList.add("arm"); } + b.classList.add("arm"); + b.textContent = "Close?"; + b.setAttribute("aria-label", hint); + b.setAttribute("title", hint); + armTimer = window.setTimeout(disarmX, ARM_MS); + } + document.addEventListener("click", function (e) { + var x = e.target && e.target.closest ? e.target.closest("#tab-bar .tab-x") : null; + // Second click on the armed button: fall through with no + // preventDefault so the form submits (the submit handler below + // upgrades it to fetch + patch, like every other form here). Only + // the timer is dropped — the pill stays red until the patched tab + // bar comes back, so the click has visible effect in flight. + // Too soon after arming it is the tail of a double-click, not a + // decision: swallow it and stay armed. + if (x && x === armedX) { + if (Date.now() - armedAt < SETTLE_MS) { e.preventDefault(); return; } + clearArm(); + return; + } + disarmX(); + if (!x) { return; } + e.preventDefault(); + armX(x); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { disarmX(); } + }); + document.addEventListener("focusout", function (e) { + if (armedX && e.target === armedX) { disarmX(); } + }); + // The editors render expanded (no-JS fallback); collapse them once // JS is live so the page opens in list-only, GitHub-style form. ["secret-editor", "session-editor", "password-editor"].forEach(function (id) { diff --git a/tests/e2e/root-sessions.spec.ts b/tests/e2e/root-sessions.spec.ts index 6354248..129f5a5 100644 --- a/tests/e2e/root-sessions.spec.ts +++ b/tests/e2e/root-sessions.spec.ts @@ -123,6 +123,62 @@ test('add a session from the tab bar, switch tabs, delete it on the settings pag await expect(row).toHaveCount(0); }); +// The tab bar's x kills a live agent and sits a few pixels from the session +// name, so it arms on the first click (a red "Close?" pill) and only closes on +// the second. Covers all three legs of that guard: the first click is inert, +// the armed state gives way to a click elsewhere and to its own timeout, and +// the second click actually closes. +test('the tab close button arms first and only closes on a second click', async ({ browser }) => { + const page = await authedPage(browser); + await page.goto('/'); + + // Add a session to close, so the test never touches "main". The name is + // auto-derived by the daemon, so take whichever tab is new. + const tabNames = () => + page.locator('#tab-bar .tab[data-tab]') + .evaluateAll((els) => els.map((e) => e.getAttribute('data-tab') as string)); + const before = await tabNames(); + await openSessionEditor(page, 'New session'); + await page.locator('#session-editor button[type="submit"]').click(); + await expect.poll(async () => (await tabNames()).length).toBe(before.length + 1); + const name = (await tabNames()).find((n) => !before.includes(n)) as string; + + const tab = page.locator(`#tab-bar .tab[data-tab="${name}"]`); + const x = page.locator(`#tab-bar .tab-x[data-close="${name}"]`); + await expect(x).toHaveText('×'); + + // First click only arms: the session is still there, and the whole tab + // tints so it is obvious which session is at stake. + await x.click(); + await expect(x).toHaveText('Close?'); + await expect(page.locator('#tab-bar .tab-wrap.arm')).toHaveCount(1); + await expect(tab).toHaveCount(1); + + // A click elsewhere disarms — a stray first click never stays loaded. + await page.locator('#tab-bar .tab[data-tab="main"]').click(); + await expect(x).toHaveText('×'); + await expect(tab).toHaveCount(1); + + // A double-click is not a considered confirmation (SETTLE_MS): it arms + // and stops there, rather than arming and immediately closing. + await x.dblclick(); + await expect(x).toHaveText('Close?'); + await expect(tab).toHaveCount(1); + + // Nor does it stay armed indefinitely (ARM_MS in settings.js). + await expect(x).toHaveText('×', { timeout: 10_000 }); + await expect(tab).toHaveCount(1); + + // Second click while armed: the tab and its pane go away. + await x.click(); + await expect(x).toHaveText('Close?'); + await page.waitForTimeout(500); // past SETTLE_MS, well inside ARM_MS + await x.click(); + await expect(page.locator('.msg')).toHaveText(/Session deleted/); + await expect(tab).toHaveCount(0); + await expect(page.locator(`#panes .pane[data-pane="${name}"]`)).toHaveCount(0); +}); + test('the working-directory picker suggests folders one level at a time', async ({ browser }) => { const page = await authedPage(browser); await page.goto('/'); diff --git a/tests/sessions.nix b/tests/sessions.nix index 2cd6f9a..7ec9b30 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -419,6 +419,13 @@ assert 'src="/agent/?arg=main"' in root_page, root_page assert "workingDirectory" not in root_page, root_page + # Each tab carries a close (x) posting to the same /sessions/delete + # route the settings page uses — a sibling form, since a
inside + # the tab would be invalid markup. The two-click arming that keeps + # a stray click from killing a session is client-side (tests/e2e). + assert '' in root_page, root_page + assert 'class="tab-x" data-close="main" aria-label="Close main"' in root_page, root_page + # The root page's CRUD routes (behind auth) can add a session; the # workspace redirect lands on the new session's tab. The name is always # auto-derived from the agent (there is no name field in the form): with