Skip to content
Draft
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,13 @@ settings page, where a sign-in and a first session are what is actually
needed. On a box with several terminal users the root lists them instead,
each behind their own login (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
settings page). The grid button at the end of the tab bar lays **every**
session out at once, each tile a live terminal with the session's name above
it, which is what you want when four agents are working and one of them is
waiting on you; press a tile's name (or its tab) to go back to one terminal
filling the page. It is a URL too (`?view=grid`), so it survives a reload and
can be bookmarked, and a session added while it is open takes its place on the
grid without one — and agents can spawn sibling sessions themselves (it's just
a file edit on their own account — handy for "have Codex cross-check this").
Open pages follow along live: a session added or removed anywhere — the CLI,
an agent, a second browser tab — appears or disappears in the tab bar (and in
Expand Down
479 changes: 430 additions & 49 deletions modules/agent-box.nix

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion modules/src/default-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ agent-box deploys as a NixOS system and as a Nix profile on an ordinary
distro, and the two differ in ways worth knowing before you change
anything outside $HOME.) Your workspace is at $AGENT_BOX_URL
(`echo $AGENT_BOX_URL` prints it): one tab per session, and each session also
has a terminal of its own at ${AGENT_BOX_URL}<session>/. Share those URLs
has a terminal of its own at ${AGENT_BOX_URL}<session>/. The grid button at
the end of the tab bar (or ${AGENT_BOX_URL}?view=grid) shows every session at
once, which is the URL to hand somebody who is watching several agents work. Share those URLs
with anyone who needs to view or take over your session; the sign-in username
is your own login name (`whoami`) and the password was set at deploy time.

Expand Down
214 changes: 189 additions & 25 deletions modules/src/settings-daemon.py

Large diffs are not rendered by default.

99 changes: 92 additions & 7 deletions modules/src/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -303,25 +303,110 @@ body.ws { display: flex; flex-direction: column; height: 100vh;
U+2699 GEAR is an Emoji_Presentation code point, so a phone swapped in
its colour emoji font and drew a shaded 3D gear beside a flat (i) —
see the note on HOME_BODY. So size the BOX here, once, for both. */
.tabs .hint, .tabs a.gear { display: flex; color: #8b949e;
padding: 0 7px 9px; }
.tabs .hint, .tabs a.gear,
.tabs a.viewtog { display: flex; color: #8b949e; padding: 0 7px 9px; }
.tabs .hint { cursor: help; }
.tabs a.gear { text-decoration: none; }
.tabs .hint svg, .tabs a.gear svg { width: 18px; height: 18px;
display: block; }
.tabs a.gear, .tabs a.viewtog { text-decoration: none; }
.tabs .hint svg, .tabs a.gear svg,
.tabs a.viewtog svg { width: 18px; height: 18px; display: block; }
/* The layout switch carries both icons and CSS picks the one the current
layout is NOT, which is the layout the button goes to. Doing it here
rather than in SCRIPT is what lets a client-side flip leave this control
correct: rewriting an href is easy, minting the other inline SVG is not
(it would be a second copy of one that lives in the daemon). */
.ws:not([data-view=grid]) .viewtog .i-single,
.ws[data-view=grid] .viewtog .i-grid { display: none; }
.tabs .hint:hover, .tabs .hint:focus-visible,
.tabs a.gear:hover, .tabs a.gear:focus-visible { color: #e6edf3; }
.tabs a.gear:hover, .tabs a.gear:focus-visible,
.tabs a.viewtog:hover,
.tabs a.viewtog:focus-visible { color: #e6edf3; }
.ws .editor, .ws .msg { margin: 8px; flex: none; }
.panes { position: relative; flex: 1; min-height: 0; }
/* A cell is a pane plus the caption naming it. Stacked and toggled the
same way the panes themselves used to be, and for the same reason: a
hidden terminal must keep its layout SIZE, or xterm re-measures a 0x0
box every time the tab changes. The caption is the grid's; here the
tab bar one row up already says the name, so it is display:none and
the pane covers the whole cell. */
.cell { position: absolute; inset: 0; visibility: hidden; }
.cell.active { visibility: visible; }
.cell-head { display: none; }
.pane { position: absolute; inset: 0; width: 100%; height: 100%;
border: 0; visibility: hidden; }
border: 0; }
/* The one pane that is not in a cell: "No session selected". */
.pane.active { visibility: visible; }
/* align-content as well as align-items: in a grid tile the message and
the Start button wrap onto two lines, and without it the wrapped lines
are spread to the full height of the tile with the button stranded near
the bottom edge. */
.pane.placeholder { display: flex; align-items: center;
align-content: center;
justify-content: center; gap: 10px;
flex-wrap: wrap; color: #8b949e; }
/* The pane's own Start button (a stopped session): the message keeps the
muted placeholder colour, the button is a button. */
.pane.placeholder .ph-msg { color: inherit; }
/* The grid layout: every session's terminal at once (data-view="grid" on
the body, set by ?view=grid and by the tab bar's toggle). It RE-LAYS
the cells that are already mounted rather than mounting a second set:
tmux sizes a session to its most recently used client, so attaching one
session twice would leave the two attachments fighting over its size.
auto-fit rather than a fixed column count: two sessions get half the
page each, eight get a scrolling grid of readable terminals, and a
phone gets one column without a media query.

The track floor is min(320px, 100%) and not a bare 320px: a bare one
cannot shrink, so on a 320px-wide phone the 8px padding either side
left 304px for a track that insisted on 320 and this container
scrolled SIDEWAYS - one column, and still not all of it on screen
(CodeRabbit on PR #580). 100% is the free space, so the floor gives
way exactly when there is less room than a tile wants. */
/* The grid REPLACES the tab strip rather than sitting under it: the two are
alternative views of the same list, so showing both reads as a grid
inside tabs, and the strip is a horizontal scroller (overflow-x on
.tabs above) - which on a phone means the page scrolls vertically while
the strip scrolls sideways, two axes where the layout promises one. At
390px the bar measured 737px of tabs in a 390px viewport. The bar's
GLOBAL controls stay: the + that adds a session, the switch back to a
single pane, the mouse hint and the gear. Selecting a session is what
the tiles are for.

Hidden in CSS rather than dropped from the markup, so toggling back
restores the strip with no re-render and the tab bar stays the one
place the layout is derived from. */
.ws[data-view=grid] .tabs .tab-wrap,
.ws[data-view=grid] .tabs .tab-empty { display: none; }
.ws[data-view=grid] .panes { position: static; display: grid; gap: 8px;
padding: 8px; overflow: auto;
grid-template-columns:
repeat(auto-fit, minmax(min(320px, 100%), 1fr));
grid-auto-rows: minmax(240px, 1fr); }
.ws[data-view=grid] .cell { position: relative; inset: auto;
visibility: visible; display: flex;
flex-direction: column; min-height: 0;
overflow: hidden; border: 1px solid #30363d;
border-radius: 8px; background: #0d1117; }
/* Which tile leaving the grid will open, and which tab is current: the
same answer, so it gets the same accent the current tab has. */
.ws[data-view=grid] .cell.active { border-color: #58a6ff; }
.ws[data-view=grid] .cell-head { display: flex; align-items: center;
gap: 8px; flex: none; padding: 5px 8px;
font-size: 12px; color: #8b949e;
text-decoration: none; background: #010409;
border-bottom: 1px solid #21262d; }
.ws[data-view=grid] .cell-head:hover,
.ws[data-view=grid] .cell-head:focus-visible { color: #e6edf3; }
/* Same treatment a long tab name gets: the label ellipsizes rather than
pushing the expand icon out of the tile (issue #236 names run to 150
characters), with the full name in the caption's tooltip. */
.cell-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; }
.cell-open { display: flex; flex: none; }
.cell-open svg { width: 14px; height: 14px; display: block; }
/* The pane is the rest of the tile, and a flex child rather than an
absolute fill: the caption above it is part of the box now. */
.ws[data-view=grid] .pane { position: static; inset: auto; flex: 1;
min-height: 0; }
/* Agent profiles (issue #321, step 5). The row folds like a session row —
.tbl li.foldrow above already gives the <li> and its <summary> their
layout, caret and hover — so this only styles what is INSIDE the fold,
Expand Down
162 changes: 146 additions & 16 deletions modules/src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,81 @@
if (st === "live" || st === "died") { return "live"; }
return st === "stopped" ? "stopped" : "starting";
}
// Which layout the page is in, and the URL that says so. `tabs` is the
// absence of the parameter (see view_href in the daemon), so a tab's own
// href keeps the shape it has always had.
function wsView() {
return document.body.getAttribute("data-view") === "grid" ? "grid" : "tabs";
}
function wsUrl(name, view) {
var q = name ? "?tab=" + encodeURIComponent(name) : "";
if (view === "grid") { q = (q ? q + "&" : "?") + "view=grid"; }
return tabBar().getAttribute("data-term-base") + q;
}
// The box around a pane, carrying the caption that names it. One cell
// shape for both layouts: the grid re-lays the cells that are already
// mounted instead of mounting a set of its own, because a session is a
// live tmux client and tmux sizes a window to its most recently used
// client: two attachments of one session would fight over its size.
function ensureCell(name) {
var cell = document.querySelector('#panes .cell[data-cell="' + name + '"]');
if (!cell) {
cell = document.createElement("div");
cell.className = "cell";
cell.setAttribute("data-cell", name);
// CLONED from a caption already on the page rather than built from
// scratch: the server renders one cell on every load, so there is
// always one to copy, and copying is what keeps the expand icon in
// the daemon instead of in a second copy here.
var tpl = document.querySelector("#panes .cell-head");
var head;
if (tpl) {
head = tpl.cloneNode(true);
} else {
head = document.createElement("a");
head.className = "cell-head";
var dotEl = document.createElement("span");
dotEl.className = "state";
var nameEl = document.createElement("span");
nameEl.className = "cell-name";
head.appendChild(dotEl);
head.appendChild(nameEl);
}
var label = "Open " + name + " full size";
head.setAttribute("data-open", name);
head.href = wsUrl(name, "tabs");
head.title = label;
head.setAttribute("aria-label", label);
head.querySelector(".cell-name").textContent = name;
cell.appendChild(head);
document.getElementById("panes").appendChild(cell);
}
// The dot is the tab's, read back on every sync: the live feed morphs
// the tab bar and never touches #panes, so this is where a session
// that has since died or stopped reaches its caption.
var dot = cell.querySelector(".cell-head .state");
if (dot) { dot.setAttribute("data-state", tabState(name) || "starting"); }
return cell;
}
// Tiles read in tab order however they were mounted: the server renders
// the selected session's cell and this script appends the rest as they
// are first shown, so DOM order is arrival order. Done with CSS `order`
// and never by moving the element. Re-inserting an iframe RELOADS it,
// which would drop the terminal inside.
//
// EVERY cell, every time, or the pass makes things worse than it found
// them: a cell left without an `order` keeps the initial value 0 and so
// sorts ahead of the ones that were given theirs.
function wsOrder() {
var names = tabNames();
document.querySelectorAll("#panes .cell[data-cell]").forEach(function (c) {
var i = names.indexOf(c.getAttribute("data-cell"));
c.style.order = String(i < 0 ? names.length : i);
});
}
function ensurePane(name) {
var cur = document.querySelector('#panes .pane[data-pane="' + name + '"]');
var cell = ensureCell(name);
var cur = cell.querySelector('.pane[data-pane="' + name + '"]');
// Keep a pane only while the state it was built for still holds. An
// iframe used to be exempt from that, so it outlived the session
// inside it: once a live session stopped (clean exit, or a stop from
Expand Down Expand Up @@ -556,12 +629,9 @@
}
el.setAttribute("data-ph", want);
el.setAttribute("data-pane", name);
if (cur) {
if (cur.classList.contains("active")) { el.classList.add("active"); }
cur.replaceWith(el);
} else {
document.getElementById("panes").appendChild(el);
}
// `active` is the CELL's, so a pane swapped in here carries no state
// of its own and cannot disagree with the cell it lands in.
if (cur) { cur.replaceWith(el); } else { cell.appendChild(el); }
return el;
}
function wsSelect(name, focus) {
Expand All @@ -572,37 +642,97 @@
else { t.removeAttribute("aria-current"); }
});
var pane = ensurePane(name);
document.querySelectorAll("#panes .pane").forEach(function (p) {
p.classList.toggle("active", p === pane);
var cell = pane.parentNode;
document.querySelectorAll("#panes .cell").forEach(function (c) {
c.classList.toggle("active", c === cell);
});
history.replaceState(null, "", bar.getAttribute("data-term-base") +
"?tab=" + encodeURIComponent(name));
history.replaceState(null, "", wsUrl(name, wsView()));
if (focus && pane.tagName === "IFRAME") {
try { pane.contentWindow.focus(); } catch (err) { /* cross-origin never happens; be safe */ }
}
}
// Switch layout and/or selected session in one move, without navigating:
// following the toggle's href for real would tear down every attached
// terminal on the page and re-attach it a moment later.
// The layout switch, after a flip this script made: the icon follows
// data-view in CSS, but the href and the accessible name are attributes
// only the server would otherwise correct - and it corrects them on its
// next render, which may be minutes away or never. Left alone, the button
// kept offering the layout it was rendered with, so pressing it twice went
// to the grid and stayed there.
function wsRetag(view, name) {
var a = document.querySelector("#tab-bar a.viewtog");
if (!a) { return; }
var to = view === "grid" ? "tabs" : "grid";
var label = view === "grid" ? "Single pane" : "Grid: every session at once";
a.setAttribute("data-view-to", to);
a.setAttribute("href", wsUrl(name || wsActive(), to));
a.setAttribute("title", label);
a.setAttribute("aria-label", label);
}
function wsGo(name, view, focus) {
var bar = tabBar();
if (!bar) { return; }
document.body.setAttribute("data-view", view);
var target = tabEl(name) ? name : wsActive();
// The grid shows every session, so anything not yet mounted is mounted
// now; leaving it keeps every pane attached, as a background tab is.
if (view === "grid") { tabNames().forEach(ensurePane); wsOrder(); }
if (target) { wsSelect(target, focus); }
else { history.replaceState(null, "", wsUrl(null, view)); }
wsRetag(view, target);
}
function wsActive() {
var bar = tabBar();
var t = bar ? bar.querySelector(".tab[aria-current]") : null;
return t ? t.getAttribute("data-tab") : null;
}
function wsSync() {
if (!tabBar()) { return; }
// Drop panes whose sessions are gone; upgrade placeholders whose
// Drop cells whose sessions are gone; upgrade placeholders whose
// sessions came live. No focus steal — the user may be typing.
document.querySelectorAll("#panes .pane[data-pane]").forEach(function (p) {
var name = p.getAttribute("data-pane");
if (!tabEl(name)) { p.remove(); return; }
document.querySelectorAll("#panes .cell[data-cell]").forEach(function (c) {
var name = c.getAttribute("data-cell");
if (!tabEl(name)) { c.remove(); return; }
ensurePane(name);
});
// A session added while the grid is open belongs on it straight away:
// that is what the layout is for. The tab layout keeps mounting a pane
// when it is first shown, so a load costs one terminal, not one each.
if (wsView() === "grid") { tabNames().forEach(ensurePane); wsOrder(); }
var cur = wsActive();
if (cur) { wsSelect(cur, false); }
}
document.addEventListener("click", function (e) {
var t = e.target && e.target.closest ? e.target.closest("#tab-bar .tab[data-tab]") : null;
if (!t) { return; }
e.preventDefault();
wsSelect(t.getAttribute("data-tab"), true);
// A tab means one session, so it selects one and leaves the grid - the
// same place its href goes with scripting off. In the grid the strip is
// hidden (see settings.css), so this is the tab layout's own handler
// there and the layout it names is the one already showing.
wsGo(t.getAttribute("data-tab"), "tabs", true);
});
// The tab bar's layout toggle, and a grid tile's caption: the two ways
// in and out of the grid. Both are real links (they work with scripting
// off); intercepting them keeps the terminals attached.
document.addEventListener("click", function (e) {
var a = e.target && e.target.closest
? e.target.closest("#tab-bar a.viewtog") : null;
if (!a) { return; }
e.preventDefault();
// Derived from the layout the page is IN rather than from the button's
// own attribute, so the switch still switches even if that attribute is
// momentarily stale (a live-feed re-render lands between the flip and
// the next click, say).
wsGo(wsActive(), wsView() === "grid" ? "tabs" : "grid", false);
});
document.addEventListener("click", function (e) {
var h = e.target && e.target.closest
? e.target.closest("#panes .cell-head[data-open]") : null;
if (!h) { return; }
e.preventDefault();
wsGo(h.getAttribute("data-open"), "tabs", true);
});

// Two-click close on the tab bar's x. Closing kills a live agent and
Expand Down
Loading