diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js
index 4008cf0c..ef2daafe 100644
--- a/turnstone/console/static/app.js
+++ b/turnstone/console/static/app.js
@@ -1610,7 +1610,12 @@ let _homeStagedFiles = [];
const _HOME_IMAGE_CAP = 4 * 1024 * 1024;
const _HOME_TEXT_CAP = 512 * 1024;
const _HOME_MAX_FILES = 10;
-const _HOME_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
+const _HOME_IMAGE_MIMES = [
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+];
const _HOME_TEXT_APP_MIMES = [
"application/json",
"application/xml",
@@ -2085,11 +2090,12 @@ function _renderHomeView() {
// ---------------------------------------------------------------------------
// Saved coordinators — closed sessions persisted on disk. Mirrors the
-// interactive UI's "Saved Workstreams" card grid (same /shared/cards.css
-// primitives, same /shared/cards.js renderSessionCard helper, same
-// response item shape from /v1/api/workstreams/saved). Click a card →
-// POST /open then /coordinator/{ws_id}; the lifted detail factory
-// lazily rehydrates from storage on the GET miss.
+// interactive UI's "Saved Workstreams" table (same /shared/cards.js
+// createSavedTable + /shared/cards.css, same response item shape from
+// /v1/api/workstreams/saved), differing only in the CHILDREN column and
+// the body-keyed delete. Click a row → POST /open then
+// /coordinator/{ws_id}; the lifted detail factory lazily rehydrates from
+// storage on the GET miss.
// ---------------------------------------------------------------------------
// In-flight de-dup for loadSavedCoordinators. ws_closed events can
@@ -2105,10 +2111,7 @@ function loadSavedCoordinators() {
// Freeze the list while the user is multi-selecting — re-rendering
// mid-mode would shuffle the visible page out from under them. The
// delete-mode wrapper drains the retry flag on cancel/onClose.
- if (
- typeof _coordDeleteController !== "undefined" &&
- _coordDeleteController.inMode()
- ) {
+ if (typeof _coordTable !== "undefined" && _coordTable.controller.inMode()) {
_savedCoordsRetry = true;
return;
}
@@ -2126,13 +2129,16 @@ function loadSavedCoordinators() {
// fetch was already in flight, defer the render — re-rendering
// mid-selection would shuffle visible cards and reshape selections.
if (
- typeof _coordDeleteController !== "undefined" &&
- _coordDeleteController.inMode()
+ typeof _coordTable !== "undefined" &&
+ _coordTable.controller.inMode()
) {
_savedCoordsRetry = true;
return;
}
- renderSavedCoordinators(data.workstreams || []);
+ const saved = data.workstreams || [];
+ const sec = document.getElementById("saved-coordinators");
+ if (sec) sec.style.display = saved.length ? "" : "none";
+ _coordTable.setItems(saved);
})
.catch(function () {
/* silent — saved list is informational, not load-bearing */
@@ -2149,178 +2155,92 @@ function loadSavedCoordinators() {
});
}
-// Saved Coordinators: paginated card list + multi-select delete.
-// The shared controller (createSavedCardsController in /shared/cards.js)
-// owns mode state, checkbox decoration, the toolbar, and the modal.
-// Pagination caps Select-All fan-out at COORD_PAGE_SIZE — the controller
-// only ever sees the visible page, so a confirm-all batch is bounded to
-// COORD_PAGE_SIZE parallel POSTs against the routing proxy.
-const COORD_PAGE_SIZE = 24;
-let _coordPage = 0;
-let _coordSavedItems = [];
-const _coordDeleteController = createSavedCardsController({
- idPrefix: "coord-delete",
- buttonId: "coord-delete-btn",
+// Saved Coordinators table — same shared createSavedTable as the server UI
+// (/shared/cards.js), with a CHILDREN column instead of MSGS and the
+// body-keyed (router-proxied) delete. Activation POSTs /open before
+// navigating so capacity limits surface as a toast, not a broken page.
+const COORD_COLUMNS = [
+ SavedColumns.name(),
+ SavedColumns.model(),
+ SavedColumns.count("child_count", "CHILDREN", "92px"),
+ SavedColumns.ctx(),
+ SavedColumns.last(),
+ SavedColumns.id(),
+];
+const _coordTable = createSavedTable({
+ headerEl: document.getElementById("coord-saved-colheaders"),
+ bodyEl: document.getElementById("saved-coord-cards"),
+ filterEl: document.getElementById("coord-filter"),
+ footerEl: document.getElementById("coord-saved-footer"),
+ columns: COORD_COLUMNS,
noun: "coordinator",
+ emptyText: "No saved coordinators",
activateLabel: function (s) {
return "Resume coordinator: " + (s.alias || s.title || s.name || s.ws_id);
},
- // Coordinators live on whichever node owns the ws_id, so we can't fire
- // a path-keyed delete the way ui/static does. The router proxy reads
- // ws_id from the body, resolves the owning node via the consistent-
- // hash ring, and forwards to that node's POST workstreams/{ws_id}/delete.
- buildDeleteRequest: function (wsId) {
- return {
- url: "/v1/api/route/workstreams/delete",
- options: {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ws_id: wsId }),
- },
- };
+ onActivate: function (s, rowEl) {
+ // POST /open BEFORE navigating so capacity issues surface as a toast
+ // instead of a broken-looking detail page.
+ if (rowEl) rowEl.classList.add("is-busy");
+ authFetch("/v1/api/workstreams/" + encodeURIComponent(s.ws_id) + "/open", {
+ method: "POST",
+ })
+ .then(function (r) {
+ if (r.ok) {
+ window.location.href = "/coordinator/" + encodeURIComponent(s.ws_id);
+ return;
+ }
+ if (rowEl) rowEl.classList.remove("is-busy");
+ if (r.status === 429) {
+ showToast(
+ "All coordinator slots are active — close one first to restore this session",
+ );
+ } else if (r.status === 404) {
+ showToast("Coordinator no longer available");
+ loadSavedCoordinators();
+ } else if (r.status === 503) {
+ showToast("Coordinator subsystem not configured");
+ } else {
+ showToast("Failed to restore coordinator (" + r.status + ")");
+ }
+ })
+ .catch(function () {
+ if (rowEl) rowEl.classList.remove("is-busy");
+ showToast("Failed to restore coordinator");
+ });
},
- render: function () {
- renderSavedCoordinators(_coordSavedItems);
- },
- onClose: function () {
- // Drain queued retries before the explicit reload — without this,
- // _savedCoordsRetry is still true from SSE events that arrived
- // during the freeze, so loadSavedCoordinators's .finally() would
- // re-fire a second fetch immediately after the first resolves.
- // Same idiom as cancelCoordDeleteMode below.
- _savedCoordsRetry = false;
- loadSavedCoordinators();
+ delete: {
+ idPrefix: "coord-delete",
+ buttonId: "coord-delete-btn",
+ // Coordinators live on whichever node owns the ws_id; the router proxy
+ // reads ws_id from the body, resolves the owning node via rendezvous
+ // hashing, and forwards to that node's POST workstreams/{ws_id}/delete.
+ buildDeleteRequest: function (wsId) {
+ return {
+ url: "/v1/api/route/workstreams/delete",
+ options: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ws_id: wsId }),
+ },
+ };
+ },
+ onClose: function () {
+ // Drain queued retries before the explicit reload (see the freeze
+ // gate in loadSavedCoordinators) so .finally() doesn't double-fetch.
+ _savedCoordsRetry = false;
+ loadSavedCoordinators();
+ },
},
});
-function renderSavedCoordinators(items) {
- _coordSavedItems = items;
- const section = document.getElementById("saved-coordinators");
- const cards = document.getElementById("saved-coord-cards");
- const countEl = document.getElementById("saved-coord-count");
- if (!section || !cards) return;
- if (!items.length) {
- section.style.display = "none";
- cards.replaceChildren();
- if (countEl) countEl.textContent = "";
- _coordPage = 0;
- if (_coordDeleteController.inMode()) _coordDeleteController.cancel();
- _renderCoordPagination();
- return;
- }
- // Clamp the page index after deletes (or upstream churn) shrink the list.
- const pages = Math.max(1, Math.ceil(items.length / COORD_PAGE_SIZE));
- if (_coordPage > pages - 1) _coordPage = pages - 1;
- if (_coordPage < 0) _coordPage = 0;
- const visible = items.slice(
- _coordPage * COORD_PAGE_SIZE,
- (_coordPage + 1) * COORD_PAGE_SIZE,
- );
- _coordDeleteController.setItems(visible);
-
- section.style.display = "";
- if (countEl) countEl.textContent = "(" + items.length + ")";
- cards.replaceChildren();
- visible.forEach(function (sess) {
- const card = renderSessionCard(sess, {
- ariaLabel: _coordDeleteController.ariaLabel,
- onActivate: function (s, cardEl) {
- if (_coordDeleteController.blockActivate()) return;
- // POST /open BEFORE navigating so capacity issues surface as a
- // toast instead of a broken-looking detail page.
- cardEl.classList.add("is-busy");
- authFetch(
- "/v1/api/workstreams/" + encodeURIComponent(s.ws_id) + "/open",
- { method: "POST" },
- )
- .then(function (r) {
- if (r.ok) {
- window.location.href =
- "/coordinator/" + encodeURIComponent(s.ws_id);
- return;
- }
- cardEl.classList.remove("is-busy");
- if (r.status === 429) {
- showToast(
- "All coordinator slots are active — close one first to restore this session",
- );
- } else if (r.status === 404) {
- showToast("Coordinator no longer available");
- loadSavedCoordinators();
- } else if (r.status === 503) {
- showToast("Coordinator subsystem not configured");
- } else {
- showToast("Failed to restore coordinator (" + r.status + ")");
- }
- })
- .catch(function () {
- cardEl.classList.remove("is-busy");
- showToast("Failed to restore coordinator");
- });
- },
- });
- _coordDeleteController.decorateCard(card, sess);
- cards.appendChild(card);
- });
- if (_coordDeleteController.inMode()) _coordDeleteController.refreshBar();
- _renderCoordPagination();
-}
-
-function _renderCoordPagination() {
- const pag = document.getElementById("coord-pagination");
- if (!pag) return;
- const total = _coordSavedItems.length;
- const pages = Math.max(1, Math.ceil(total / COORD_PAGE_SIZE));
- // Single-page lists and delete-mode hide the controls — page changes
- // would invalidate the user's checkbox selections, so we lock them out.
- if (pages <= 1 || _coordDeleteController.inMode()) {
- pag.style.display = "none";
- return;
- }
- pag.style.display = "";
- const label = document.getElementById("coord-page-label");
- if (label) {
- /* Visible text uses the terse "X / Y" form to match the
- filtered-pagination control elsewhere in the console; the long
- form sits on the parent's aria-label so screen readers still get
- a full sentence. */
- label.textContent = _coordPage + 1 + " / " + pages;
- pag.setAttribute(
- "aria-label",
- "Saved coordinators pagination — page " +
- (_coordPage + 1) +
- " of " +
- pages,
- );
- }
- const prev = document.getElementById("coord-page-prev");
- if (prev) prev.disabled = _coordPage <= 0;
- const next = document.getElementById("coord-page-next");
- if (next) next.disabled = _coordPage >= pages - 1;
-}
-
-function coordPagePrev() {
- if (_coordPage > 0) {
- _coordPage--;
- renderSavedCoordinators(_coordSavedItems);
- }
-}
-
-function coordPageNext() {
- const pages = Math.max(1, Math.ceil(_coordSavedItems.length / COORD_PAGE_SIZE));
- if (_coordPage < pages - 1) {
- _coordPage++;
- renderSavedCoordinators(_coordSavedItems);
- }
-}
-
// HTML inline-onclick wrappers — keep the global names the markup binds
// to and forward to the shared controller.
function startCoordDeleteMode() {
- _coordDeleteController.start();
+ _coordTable.controller.start();
}
function cancelCoordDeleteMode() {
- _coordDeleteController.cancel();
+ _coordTable.controller.cancel();
// The freeze gate (see loadSavedCoordinators) may have queued retries
// while we were multi-selecting; drain them now that we're idle again.
if (_savedCoordsRetry) {
@@ -2329,16 +2249,16 @@ function cancelCoordDeleteMode() {
}
}
function toggleCoordSelectAll() {
- _coordDeleteController.toggleAll();
+ _coordTable.controller.toggleAll();
}
function confirmCoordDeleteSelection() {
- _coordDeleteController.confirmSelection();
+ _coordTable.controller.confirmSelection();
}
function cancelCoordDelete() {
- _coordDeleteController.closeModal();
+ _coordTable.controller.closeModal();
}
function confirmCoordDelete() {
- _coordDeleteController.confirm();
+ _coordTable.controller.confirm();
}
// --- Init ---
diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html
index b298f4fb..f114cc33 100644
--- a/turnstone/console/static/index.html
+++ b/turnstone/console/static/index.html
@@ -144,48 +144,41 @@
style="display: none"
aria-label="Saved coordinators"
>
-
- Saved Coordinators
-
-
-
+
+
+ class="saved-footer"
+ id="coord-saved-footer"
+ role="status"
+ aria-live="polite"
+ aria-atomic="true"
+ >
=0.15 tint keeps it legible at chip size. */
+.skill-chip {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 9.5px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: #aeb8db;
+ background: rgba(120, 140, 210, 0.16);
+ border: 1px solid rgba(120, 140, 210, 0.34);
+ border-radius: 10px;
+ padding: 1px 7px 1px 6px;
+}
+.skill-chip-g {
+ font-size: 8px;
+ opacity: 0.85;
+}
+[data-theme="light"] .skill-chip {
+ color: #44507a;
+ background: rgba(90, 110, 180, 0.14);
+ border-color: rgba(90, 110, 180, 0.4);
+}
+
+/* Footer count line — mirrors the active table's footer altitude. */
+.saved-footer {
+ padding: 9px 16px;
+ font-size: 11px;
+ color: var(--fg-dim);
+}
+
+/* Delete mode on rows: the shared controller inserts an absolute checkbox
+ (positioned top-right for cards); reposition to the row's left edge and
+ shift the grid so it doesn't collide with NAME. */
+.dash-row .ws-card-check {
+ top: 50%;
+ left: 10px;
+ right: auto;
+ transform: translateY(-50%);
+}
+.dash-row.ws-delete-mode .dash-row-main {
+ padding-left: 34px;
+}
+/* Keep the column headers registered with the rows while multi-selecting:
+ rows reserve a 34px checkbox gutter, so the headers shift to match. */
+.dash-colheaders.saved-cols-delete {
+ padding-left: 34px;
+}
+.dash-row.ws-selected {
+ background: rgba(248, 113, 113, 0.08);
+}
+[data-theme="light"] .dash-row.ws-selected {
+ background: rgba(220, 38, 38, 0.08);
+}
diff --git a/turnstone/shared_static/cards.js b/turnstone/shared_static/cards.js
index ac5f51d5..3a65cb73 100644
--- a/turnstone/shared_static/cards.js
+++ b/turnstone/shared_static/cards.js
@@ -1,81 +1,459 @@
-/* Shared session card primitive — used by ui/static (Saved Workstreams)
- and console/static (Saved Coordinators). Single source so the two
- surfaces don't drift on field cascade, ARIA roles, keyboard handling,
- or DOM shape.
+/* Shared saved-list primitives — used by ui/static (Saved Workstreams) and
+ console/static (Saved Coordinators). Single source so the two surfaces
+ don't drift on row shape, ARIA, keyboard handling, filter/sort, or the
+ delete affordance:
- Card structure (also see /shared/cards.css for styling):
+ - renderSessionRow(sess, opts) — one .dash-row from a column spec
+ - SavedColumns — shared column descriptors
+ - createSavedTable(opts) — filter + sort + render, wrapping the
+ multi-select delete controller
+ - createSavedCardsController — the delete-mode controller (below)
- .dashboard-card role="button" tabindex="0"
- .card-title sess.alias || title || name || ws_id[:12]
- .card-meta "X msgs · Y ago "
- .card-wsid ws_id[:7]
-
- Built with safe DOM APIs (createElement + textContent) — never
- innerHTML — so user-supplied alias/title/name fields don't reach the
- DOM as HTML.
-
- Caller passes:
- sess — {ws_id, alias?, title?, name?, message_count?, updated?}
- opts.onActivate(sess) — fired on click + Enter/Space
- opts.ariaLabel(sess)? — optional aria-label override; default
- "Resume: {label}"
- opts.busy? — boolean; adds `is-busy` class (visual dim
- + cursor: progress) and suppresses re-entry
- into onActivate.
-
- Returns the card DOM node. Caller appends it.
-
- Depends on: formatRelativeTime (from /shared/utils.js).
+ Built with safe DOM APIs (createElement + textContent), never innerHTML,
+ so user-supplied alias/title/name/skill fields never reach the DOM as
+ HTML. Depends on formatRelativeTime (from /shared/utils.js).
*/
-function renderSessionCard(sess, opts) {
+/* ==========================================================================
+ Saved-list TABLE primitives — the row builder (renderSessionRow) plus a
+ shared filter / sort / render orchestrator (createSavedTable). Both the
+ server UI (Saved Workstreams) and the console (Saved Coordinators) build
+ their saved list from these so the two surfaces can't drift. The only
+ per-surface input is the column spec (MSGS vs CHILDREN), the DOM refs,
+ and the delete-request shape — everything generic lives here.
+ ========================================================================== */
+
+/* Map a 0..1 context-occupancy ratio to a coloured CTX cell using the
+ active table's bands (base.css .dash-cell-ctx.ctx-*). 0 / unknown
+ renders as a dim em-dash, not "0%": a saved row with no recorded usage
+ (or a model whose window isn't in model_definitions) has no occupancy to
+ report. The value is a frozen snapshot from the last turn, not live. */
+function _ctxCell(sess) {
+ var ratio = typeof sess.context_ratio === "number" ? sess.context_ratio : 0;
+ var span = document.createElement("span");
+ span.className = "dash-cell-ctx";
+ if (ratio <= 0) {
+ span.classList.add("ctx-idle");
+ span.textContent = "—";
+ return span;
+ }
+ var level =
+ ratio > 0.95
+ ? "ctx-danger"
+ : ratio > 0.8
+ ? "ctx-high"
+ : ratio > 0.5
+ ? "ctx-mid"
+ : "ctx-low";
+ span.classList.add(level);
+ span.textContent = Math.round(ratio * 100) + "%";
+ return span;
+}
+
+/* NAME cell: ellipsised title + an optional skill chip when the workstream
+ launched with a non-default skill (empty for "Use defaults"). */
+function _nameCell(sess) {
+ var wrap = document.createElement("div");
+ wrap.className = "scell-name";
+ var nm = document.createElement("span");
+ nm.className = "scell-nm";
+ nm.textContent =
+ sess.alias || sess.title || sess.name || sess.ws_id.substring(0, 12);
+ wrap.appendChild(nm);
+ if (sess.launch_skill) {
+ var chip = document.createElement("span");
+ chip.className = "skill-chip";
+ var g = document.createElement("span");
+ g.className = "skill-chip-g";
+ g.setAttribute("aria-hidden", "true");
+ g.textContent = "◆";
+ chip.appendChild(g);
+ chip.appendChild(document.createTextNode(sess.launch_skill));
+ wrap.appendChild(chip);
+ }
+ return wrap;
+}
+
+/* Column factory — shared descriptors. Each: {key, label, width, align,
+ cell(sess)->Node|string, sort(sess)->comparable}. The only difference
+ between the two surfaces is count("message_count","MSGS") vs
+ count("child_count","CHILDREN"). */
+var SavedColumns = {
+ name: function () {
+ return {
+ key: "name",
+ label: "NAME",
+ width: "minmax(0,1fr)",
+ cell: _nameCell,
+ sort: function (s) {
+ return (s.alias || s.title || s.name || s.ws_id).toLowerCase();
+ },
+ };
+ },
+ model: function () {
+ return {
+ key: "model",
+ label: "MODEL",
+ width: "150px",
+ cls: "scell-model",
+ hideBelow: true,
+ cell: function (s) {
+ return s.model_alias || "—";
+ },
+ sort: function (s) {
+ return (s.model_alias || "").toLowerCase();
+ },
+ };
+ },
+ count: function (field, label, width) {
+ return {
+ key: field,
+ label: label,
+ width: width || "72px",
+ align: "right",
+ cell: function (s) {
+ return String(s[field] != null ? s[field] : 0);
+ },
+ sort: function (s) {
+ return s[field] != null ? s[field] : 0;
+ },
+ };
+ },
+ ctx: function () {
+ return {
+ key: "context_ratio",
+ label: "CTX",
+ width: "56px",
+ align: "right",
+ title: "Context window used as of last activity",
+ cell: _ctxCell,
+ sort: function (s) {
+ return typeof s.context_ratio === "number" ? s.context_ratio : 0;
+ },
+ };
+ },
+ last: function () {
+ return {
+ key: "updated",
+ label: "LAST",
+ width: "62px",
+ align: "right",
+ cell: function (s) {
+ return typeof formatRelativeTime === "function"
+ ? formatRelativeTime(s.updated)
+ : s.updated || "";
+ },
+ sort: function (s) {
+ return s.updated || "";
+ },
+ };
+ },
+ id: function () {
+ return {
+ key: "ws_id",
+ label: "ID",
+ width: "76px",
+ align: "right",
+ cls: "scell-id",
+ hideBelow: true,
+ cell: function (s) {
+ return s.ws_id.substring(0, 7);
+ },
+ sort: function (s) {
+ return s.ws_id;
+ },
+ };
+ },
+};
+
+/* Builds one saved-list .dash-row from a column spec.
+ Saved rows reuse the dash-table chrome but opt OUT of the active table's
+ live-state styling — only an `error` state is carried (for the red
+ left-edge); idle/running/etc. are not, so a terminal, mostly-idle saved
+ list isn't dimmed by base.css's `[data-state="idle"]` rule. The grid
+ template comes from the `--saved-grid` CSS var that createSavedTable sets
+ once per render (not rebuilt per row). */
+function renderSessionRow(sess, opts) {
opts = opts || {};
- var card = document.createElement("div");
- card.className = "dashboard-card" + (opts.busy ? " is-busy" : "");
- card.dataset.wsId = sess.ws_id;
- var label = sess.alias || sess.title || sess.name || sess.ws_id;
- card.setAttribute("role", "button");
- card.setAttribute("tabindex", "0");
- card.setAttribute(
+ var columns = opts.columns || [];
+ var row = document.createElement("div");
+ row.className = "dash-row saved-row" + (opts.busy ? " is-busy" : "");
+ row.dataset.wsId = sess.ws_id;
+ if (sess.state === "error") row.dataset.state = "error";
+ row.setAttribute("role", "button");
+ row.setAttribute("tabindex", "0");
+ row.setAttribute(
"aria-label",
typeof opts.ariaLabel === "function"
? opts.ariaLabel(sess)
- : "Resume: " + label,
+ : "Resume: " + (sess.alias || sess.title || sess.name || sess.ws_id),
);
-
+ var main = document.createElement("div");
+ main.className = "dash-row-main";
+ columns.forEach(function (col) {
+ var cell = document.createElement("div");
+ cell.className = "scell" + (col.align === "right" ? " scell-r" : "");
+ if (col.cls) cell.classList.add(col.cls);
+ var content = col.cell(sess);
+ if (content instanceof Node) cell.appendChild(content);
+ else cell.textContent = content;
+ main.appendChild(cell);
+ });
+ row.appendChild(main);
var activate = function () {
- if (card.classList.contains("is-busy")) return;
- if (typeof opts.onActivate === "function") opts.onActivate(sess, card);
+ if (row.classList.contains("is-busy")) return;
+ if (typeof opts.onActivate === "function") opts.onActivate(sess, row);
};
- card.onclick = activate;
- card.onkeydown = function (e) {
+ row.onclick = activate;
+ row.onkeydown = function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate();
}
};
+ return row;
+}
- var title =
- sess.alias || sess.title || sess.name || sess.ws_id.substring(0, 12);
- var titleEl = document.createElement("div");
- titleEl.className = "card-title";
- titleEl.textContent = title;
+/* Shared saved-list table: owns client-side filter + sort + render and
+ wraps the existing multi-select delete controller. Apps pass DOM refs +
+ a column spec + the delete-request shape; the per-app delete-bar HTML
+ keeps wiring its inline onclick thunks to `table.controller.*`.
- var metaEl = document.createElement("div");
- metaEl.className = "card-meta";
- var metaText = (sess.message_count || 0) + " msgs";
- if (sess.updated && typeof formatRelativeTime === "function") {
- metaText += " · " + formatRelativeTime(sess.updated);
+ opts:
+ headerEl, bodyEl — the .dash-colheaders + .dash-table elements
+ filterEl — optional for the client-side name filter
+ footerEl — optional element for the count line
+ columns — array from SavedColumns
+ noun — "workstream" / "coordinator"
+ onActivate — sess => void (resume); gated by delete mode
+ activateLabel — optional sess => string (aria when not deleting)
+ emptyText — empty-state copy
+ delete — {idPrefix, buttonId, buildDeleteRequest, onClose}
+ returns { setItems(items), render(), controller }. */
+function createSavedTable(opts) {
+ var state = {
+ items: [],
+ filter: "",
+ sortKey: "updated",
+ sortDir: -1,
+ compact: false,
+ };
+
+ var controller = createSavedCardsController({
+ idPrefix: opts.delete.idPrefix,
+ buttonId: opts.delete.buttonId,
+ noun: opts.noun,
+ activateLabel:
+ opts.activateLabel ||
+ function (s) {
+ return "Resume: " + (s.alias || s.title || s.name || s.ws_id);
+ },
+ buildDeleteRequest: opts.delete.buildDeleteRequest,
+ render: function () {
+ render();
+ },
+ onClose: opts.delete.onClose,
+ });
+
+ function matches(sess) {
+ if (!state.filter) return true;
+ var hay = (
+ (sess.alias || "") +
+ " " +
+ (sess.title || "") +
+ " " +
+ (sess.name || "") +
+ " " +
+ sess.ws_id
+ ).toLowerCase();
+ return hay.indexOf(state.filter) !== -1;
}
- metaEl.appendChild(document.createTextNode(metaText + " "));
- var wsidEl = document.createElement("span");
- wsidEl.className = "card-wsid";
- wsidEl.textContent = sess.ws_id.substring(0, 7);
- metaEl.appendChild(wsidEl);
- card.appendChild(titleEl);
- card.appendChild(metaEl);
- return card;
+ function column(key) {
+ for (var i = 0; i < opts.columns.length; i++) {
+ if (opts.columns[i].key === key) return opts.columns[i];
+ }
+ return null;
+ }
+
+ /* On narrow viewports drop the lower-value columns (those flagged
+ hideBelow — model, id) so NAME, the column this redesign exists to keep
+ readable, never collapses to zero. */
+ function visibleColumns() {
+ return opts.columns.filter(function (c) {
+ return !(state.compact && c.hideBelow);
+ });
+ }
+
+ function gridTemplate(cols) {
+ return cols
+ .map(function (c) {
+ return c.width;
+ })
+ .join(" ");
+ }
+
+ function sorted() {
+ var col = column(state.sortKey) || column("updated");
+ var out = state.items.filter(matches);
+ if (col) {
+ out.sort(function (a, b) {
+ var av = col.sort(a);
+ var bv = col.sort(b);
+ if (av < bv) return -state.sortDir;
+ if (av > bv) return state.sortDir;
+ return 0;
+ });
+ }
+ return out;
+ }
+
+ function renderHeaders(cols) {
+ if (!opts.headerEl) return;
+ opts.headerEl.style.gridTemplateColumns = gridTemplate(cols);
+ /* Shift the headers in lockstep with the rows' checkbox gutter so the
+ columns stay registered while multi-selecting. */
+ opts.headerEl.classList.toggle("saved-cols-delete", controller.inMode());
+ opts.headerEl.replaceChildren();
+ cols.forEach(function (col) {
+ var active = col.key === state.sortKey;
+ var h = document.createElement("span");
+ h.className =
+ "scol" +
+ (col.align === "right" ? " scell-r" : "") +
+ (active ? " sorted" : "");
+ h.setAttribute("role", "button");
+ h.setAttribute("tabindex", "0");
+ h.setAttribute("aria-label", "Sort by " + col.label);
+ h.setAttribute(
+ "aria-sort",
+ active ? (state.sortDir < 0 ? "descending" : "ascending") : "none",
+ );
+ if (col.title) h.title = col.title;
+ h.appendChild(document.createTextNode(col.label));
+ /* Every sortable header carries a caret so the affordance is
+ discoverable at rest — inactive ones faint, the active one
+ directional. */
+ var car = document.createElement("span");
+ car.className = "caret" + (active ? "" : " caret-idle");
+ car.setAttribute("aria-hidden", "true");
+ car.textContent = active ? (state.sortDir < 0 ? "▼" : "▲") : "↕";
+ h.appendChild(car);
+ function doSort() {
+ if (state.sortKey === col.key) {
+ state.sortDir = -state.sortDir;
+ } else {
+ state.sortKey = col.key;
+ /* text columns default A→Z, everything else newest/highest-first */
+ state.sortDir = col.key === "name" || col.key === "model" ? 1 : -1;
+ }
+ render();
+ }
+ h.onclick = doSort;
+ h.onkeydown = function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ doSort();
+ }
+ };
+ opts.headerEl.appendChild(h);
+ });
+ }
+
+ function renderFooter(visibleCount) {
+ if (!opts.footerEl) return;
+ var total = state.items.length;
+ var noun = opts.noun + (total === 1 ? "" : "s");
+ /* The empty/filtered body message owns the "no match" copy; the footer
+ stays a plain total so the two don't say the same thing twice. */
+ if (state.filter && visibleCount > 0 && visibleCount !== total) {
+ opts.footerEl.textContent =
+ visibleCount +
+ " of " +
+ total +
+ " " +
+ noun +
+ " match “" +
+ state.filter +
+ "”";
+ } else {
+ opts.footerEl.textContent = total + " " + noun;
+ }
+ }
+
+ function render() {
+ var cols = visibleColumns();
+ var rows = sorted();
+ controller.setItems(rows);
+ /* One grid write per render: rows read it from the inherited CSS var. */
+ if (opts.bodyEl) {
+ opts.bodyEl.style.setProperty("--saved-grid", gridTemplate(cols));
+ opts.bodyEl.replaceChildren();
+ }
+ if (!rows.length) {
+ /* Empty state owns the space — hide the column headers so it doesn't
+ read as a broken grid. */
+ if (opts.headerEl) opts.headerEl.style.display = "none";
+ var empty = document.createElement("div");
+ empty.className = "dashboard-empty";
+ empty.textContent = state.filter
+ ? "No " + opts.noun + "s match “" + state.filter + "”"
+ : opts.emptyText || "No saved items";
+ if (opts.bodyEl) opts.bodyEl.appendChild(empty);
+ } else {
+ if (opts.headerEl) opts.headerEl.style.display = "";
+ renderHeaders(cols);
+ rows.forEach(function (sess) {
+ var row = renderSessionRow(sess, {
+ columns: cols,
+ ariaLabel: controller.ariaLabel,
+ onActivate: function (s, el) {
+ if (controller.blockActivate()) return;
+ if (typeof opts.onActivate === "function") opts.onActivate(s, el);
+ },
+ });
+ controller.decorateCard(row, sess);
+ opts.bodyEl.appendChild(row);
+ });
+ }
+ if (controller.inMode()) controller.refreshBar();
+ renderFooter(rows.length);
+ }
+
+ /* Debounce only the filter keystrokes; setItems / sort / delete render
+ immediately. */
+ var filterTimer = null;
+ if (opts.filterEl) {
+ opts.filterEl.addEventListener("input", function () {
+ if (filterTimer) clearTimeout(filterTimer);
+ filterTimer = setTimeout(function () {
+ state.filter = opts.filterEl.value.trim().toLowerCase();
+ render();
+ }, 120);
+ });
+ }
+
+ /* Saved table owns its responsive layout: below the breakpoint the
+ hideBelow columns drop and NAME reclaims the width. */
+ if (typeof window !== "undefined" && window.matchMedia) {
+ var mq = window.matchMedia("(max-width: 760px)");
+ state.compact = mq.matches;
+ var onMq = function (e) {
+ state.compact = e.matches;
+ render();
+ };
+ if (mq.addEventListener) mq.addEventListener("change", onMq);
+ else if (mq.addListener) mq.addListener(onMq);
+ }
+
+ return {
+ setItems: function (items) {
+ state.items = items || [];
+ render();
+ },
+ render: render,
+ controller: controller,
+ };
}
/* createSavedCardsController — shared multi-select-delete behaviour for
@@ -178,7 +556,7 @@ function createSavedCardsController(opts) {
: "Activate: " + label;
}
- /* Decorate an already-rendered .dashboard-card with the checkbox +
+ /* Decorate an already-rendered saved row (.dash-row) with the checkbox +
event overrides used in delete mode. Idempotent guard: only acts
when the controller is active. */
function decorateCard(card, sess) {
diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js
index 5a19a376..410f2396 100644
--- a/turnstone/ui/static/app.js
+++ b/turnstone/ui/static/app.js
@@ -4075,13 +4075,11 @@ function toggleDashboard() {
// area. Clears any cards AND hides the pagination control \u2014 it's a sibling
// of the cards container, so a bare replaceChildren on the cards alone would
// leave stale Prev/Next visible and still wired to the previous list cache.
-// A successful load re-shows both via renderSavedWorkstreams.
+// A successful load re-shows both via _wsTable.setItems.
function _setSavedWsMessage(text) {
document
.getElementById("dashboard-saved-cards")
.replaceChildren(makeEmptyState(text));
- const pag = document.getElementById("ws-pagination");
- if (pag) pag.style.display = "none";
}
function loadDashboard() {
@@ -4107,7 +4105,7 @@ function loadDashboard() {
const savedList = (res[1].workstreams || []).filter(function (s) {
return !activeWsIds[s.ws_id];
});
- renderSavedWorkstreams(savedList);
+ _wsTable.setItems(savedList);
})
.catch(function () {
tableEl.replaceChildren(makeEmptyState("Failed to load"));
@@ -4256,157 +4254,68 @@ function updateDashFooter(agg) {
}
}
-// Saved Workstreams cache + multi-select delete controller. The
-// controller (from /shared/cards.js) owns mode state, checkbox
-// decoration, the toolbar wiring, and the confirmation modal — see
-// createSavedCardsController for the shared bits. Page size + clamp
-// logic mirror the coordinator launcher (console/static) so the two
-// dashboards stay consistent; the controller only ever sees the visible
-// page, bounding Select-All fan-out to WS_PAGE_SIZE.
-const WS_PAGE_SIZE = 24;
-let _wsPage = 0;
-let _wsSavedItems = [];
-const _wsDeleteController = createSavedCardsController({
- idPrefix: "ws-delete",
- buttonId: "ws-delete-btn",
+// Saved Workstreams table. The shared createSavedTable (/shared/cards.js)
+// owns filter + sort + render and wraps the multi-select delete controller;
+// the per-app inputs are the column spec, the DOM refs, and the path-keyed
+// delete request. Coordinators (console/static) use the same helper with a
+// CHILDREN column instead of MSGS.
+const WS_COLUMNS = [
+ SavedColumns.name(),
+ SavedColumns.model(),
+ SavedColumns.count("message_count", "MSGS"),
+ SavedColumns.ctx(),
+ SavedColumns.last(),
+ SavedColumns.id(),
+];
+const _wsTable = createSavedTable({
+ headerEl: document.getElementById("ws-saved-colheaders"),
+ bodyEl: document.getElementById("dashboard-saved-cards"),
+ filterEl: document.getElementById("ws-filter"),
+ footerEl: document.getElementById("ws-saved-footer"),
+ columns: WS_COLUMNS,
noun: "workstream",
+ emptyText: "No saved workstreams",
activateLabel: function (s) {
return "Resume: " + (s.alias || s.title || s.ws_id);
},
- buildDeleteRequest: function (wsId) {
- return {
- url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
- options: { method: "POST" },
- };
+ onActivate: function (s) {
+ dashboardResumeSession(s.ws_id);
},
- render: function () {
- renderSavedWorkstreams(_wsSavedItems);
- },
- onClose: function () {
- loadDashboard();
+ delete: {
+ idPrefix: "ws-delete",
+ buttonId: "ws-delete-btn",
+ buildDeleteRequest: function (wsId) {
+ return {
+ url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
+ options: { method: "POST" },
+ };
+ },
+ onClose: function () {
+ loadDashboard();
+ },
},
});
-function renderSavedWorkstreams(items) {
- _wsSavedItems = items;
- const c = document.getElementById("dashboard-saved-cards");
- c.replaceChildren();
- if (!items.length) {
- _wsPage = 0;
- // If the list empties while delete mode is open (e.g. external churn
- // removes the last card), drop out of delete mode so the toolbar
- // doesn't linger over an empty grid — matches the coordinator
- // launcher. cancel() re-renders via the controller's callback, which
- // re-enters here with the mode off and paints the empty state, so
- // return and let that pass own the DOM (re-appending here would
- // double the empty-state row, since — unlike the console, which hides
- // a section — this dashboard appends an empty node).
- if (_wsDeleteController.inMode()) {
- _wsDeleteController.cancel();
- return;
- }
- _wsDeleteController.setItems(items);
- const empty = document.createElement("div");
- empty.className = "dashboard-empty";
- empty.textContent = "No saved workstreams";
- c.appendChild(empty);
- _renderWsPagination();
- return;
- }
- // Clamp the page index after deletes (or upstream churn) shrink the list.
- const pages = Math.max(1, Math.ceil(items.length / WS_PAGE_SIZE));
- if (_wsPage > pages - 1) _wsPage = pages - 1;
- if (_wsPage < 0) _wsPage = 0;
- const visible = items.slice(
- _wsPage * WS_PAGE_SIZE,
- (_wsPage + 1) * WS_PAGE_SIZE,
- );
- // The controller only sees the visible page so its Select-All / count
- // can't reach off-page cards that aren't in the DOM.
- _wsDeleteController.setItems(visible);
- visible.forEach(function (sess) {
- const card = renderSessionCard(sess, {
- ariaLabel: _wsDeleteController.ariaLabel,
- onActivate: function (s) {
- if (_wsDeleteController.blockActivate()) return;
- dashboardResumeSession(s.ws_id);
- },
- });
- _wsDeleteController.decorateCard(card, sess);
- c.appendChild(card);
- });
- if (_wsDeleteController.inMode()) _wsDeleteController.refreshBar();
- _renderWsPagination();
-}
-
-// Twin of the console launcher's _renderCoordPagination / coordPagePrev /
-// coordPageNext (console/static/app.js) — keep the two in sync when the
-// paging behaviour changes. Only the shared .pagination CSS is de-duped;
-// the render wiring stays per-app because it binds per-app DOM ids and
-// controller instances.
-function _renderWsPagination() {
- const pag = document.getElementById("ws-pagination");
- if (!pag) return;
- const total = _wsSavedItems.length;
- const pages = Math.max(1, Math.ceil(total / WS_PAGE_SIZE));
- // Single-page lists and delete-mode hide the controls — paging would
- // invalidate the user's checkbox selections, so we lock them out.
- if (pages <= 1 || _wsDeleteController.inMode()) {
- pag.style.display = "none";
- return;
- }
- pag.style.display = "";
- const label = document.getElementById("ws-page-label");
- if (label) {
- /* Visible text uses the terse "X / Y" form; the long form sits on the
- parent's aria-label so screen readers get a full sentence. */
- label.textContent = _wsPage + 1 + " / " + pages;
- pag.setAttribute(
- "aria-label",
- "Saved workstreams pagination — page " + (_wsPage + 1) + " of " + pages,
- );
- }
- const prev = document.getElementById("ws-page-prev");
- if (prev) prev.disabled = _wsPage <= 0;
- const next = document.getElementById("ws-page-next");
- if (next) next.disabled = _wsPage >= pages - 1;
-}
-
-function wsPagePrev() {
- if (_wsPage > 0) {
- _wsPage--;
- renderSavedWorkstreams(_wsSavedItems);
- }
-}
-
-function wsPageNext() {
- const pages = Math.max(1, Math.ceil(_wsSavedItems.length / WS_PAGE_SIZE));
- if (_wsPage < pages - 1) {
- _wsPage++;
- renderSavedWorkstreams(_wsSavedItems);
- }
-}
-
-// HTML inline-onclick wrappers — keep the global names the existing
-// markup binds to (`onclick="startWsDeleteMode()"` etc.) and forward
-// to the controller.
+// HTML inline-onclick wrappers — keep the global names the existing markup
+// binds to (`onclick="startWsDeleteMode()"` etc.) and forward to the shared
+// table's delete controller.
function startWsDeleteMode() {
- _wsDeleteController.start();
+ _wsTable.controller.start();
}
function cancelWsDeleteMode() {
- _wsDeleteController.cancel();
+ _wsTable.controller.cancel();
}
function toggleSelectAll() {
- _wsDeleteController.toggleAll();
+ _wsTable.controller.toggleAll();
}
function confirmWsDeleteSelection() {
- _wsDeleteController.confirmSelection();
+ _wsTable.controller.confirmSelection();
}
function cancelWsDelete() {
- _wsDeleteController.closeModal();
+ _wsTable.controller.closeModal();
}
function confirmWsDelete() {
- _wsDeleteController.confirm();
+ _wsTable.controller.confirm();
}
// --- Workstream title management ---
diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html
index 9a6e7754..d38bcd5e 100644
--- a/turnstone/ui/static/index.html
+++ b/turnstone/ui/static/index.html
@@ -203,34 +203,41 @@
id="dashboard-saved-ws"
aria-label="Saved workstreams"
>
-
+