mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(ui): saved workstreams & coordinators — card grid → sortable table
Replaces the Saved Workstreams (ui/static) and Saved Coordinators (console/static) card grids with a dense, sortable table that reuses the active dashboard's row system, via one shared component in shared_static/cards.js (renderSessionRow, SavedColumns, createSavedTable) + cards.css. The two surfaces differ only by column spec (MSGS vs CHILDREN) and per-app data/ids/delete-request; everything generic is shared. - NAME flexes to full width (kills the card grid's near-duplicate-name truncation); client-side filter + sortable headers; scroll-all (pagination retired); multi-select delete preserved on rows. - Consumes the enriched saved-list DTO: MODEL, CTX (context-window occupancy, a frozen last-activity snapshot), SKILL chip, CHILDREN, and a red left-edge for failed runs. - Saved rows reuse the dash-table chrome but opt out of the active table's live-state styling: idle rows aren't dimmed, CTX reads as a snapshot (not the live gauge), legible zebra + AA-contrast muted text for a long terminal list, and responsive compact columns keep NAME readable on narrow viewports. - a11y: sortable headers exposed to assistive tech (aria-label / aria-sort + at-rest carets); footers are live regions. - Removes the now-dead renderSessionCard + card-grid CSS.
This commit is contained in:
+96
-176
@@ -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 ---
|
||||
|
||||
@@ -144,48 +144,41 @@
|
||||
style="display: none"
|
||||
aria-label="Saved coordinators"
|
||||
>
|
||||
<h2 class="home-section-title">
|
||||
<span>Saved Coordinators</span>
|
||||
<span id="saved-coord-count" class="home-section-count"></span>
|
||||
<button
|
||||
id="coord-delete-btn"
|
||||
class="ws-delete-btn home-section-action"
|
||||
onclick="startCoordDeleteMode()"
|
||||
title="Delete coordinators"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span> Delete
|
||||
</button>
|
||||
</h2>
|
||||
<div class="dash-header">
|
||||
<span class="dash-header-title">SAVED COORDINATORS</span>
|
||||
<div class="saved-toolbar">
|
||||
<input
|
||||
id="coord-filter"
|
||||
class="saved-filter"
|
||||
type="search"
|
||||
placeholder="Filter by name…"
|
||||
aria-label="Filter saved coordinators"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="coord-delete-btn"
|
||||
class="ws-delete-btn"
|
||||
onclick="startCoordDeleteMode()"
|
||||
title="Delete coordinators"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-colheaders" id="coord-saved-colheaders"></div>
|
||||
<div
|
||||
class="dash-table"
|
||||
id="saved-coord-cards"
|
||||
class="dashboard-cards"
|
||||
role="list"
|
||||
aria-live="polite"
|
||||
role="group"
|
||||
aria-label="Saved coordinators"
|
||||
></div>
|
||||
<div
|
||||
id="coord-pagination"
|
||||
class="pagination"
|
||||
style="display: none"
|
||||
role="navigation"
|
||||
aria-label="Saved coordinators pagination"
|
||||
>
|
||||
<button
|
||||
id="coord-page-prev"
|
||||
type="button"
|
||||
onclick="coordPagePrev()"
|
||||
>
|
||||
◄ Prev
|
||||
</button>
|
||||
<span id="coord-page-label" aria-live="polite" aria-atomic="true">
|
||||
</span>
|
||||
<button
|
||||
id="coord-page-next"
|
||||
type="button"
|
||||
onclick="coordPageNext()"
|
||||
>
|
||||
Next ►
|
||||
</button>
|
||||
</div>
|
||||
class="saved-footer"
|
||||
id="coord-saved-footer"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<div id="coord-delete-bar" class="ws-delete-bar">
|
||||
<span
|
||||
class="ws-delete-count-label"
|
||||
|
||||
@@ -157,10 +157,8 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Saved Coordinators reuses the shared .pagination control (defined in
|
||||
/shared/cards.css) — the visible page is capped at COORD_PAGE_SIZE so
|
||||
Select-All fan-out is bounded. Pagination is hidden in delete mode and
|
||||
when there's only one page (see _renderCoordPagination). */
|
||||
/* Saved Coordinators scroll-all (no pagination); the shared createSavedTable
|
||||
in /shared/cards.js owns filter + sort + render. */
|
||||
|
||||
.home-coord-list {
|
||||
border: 1px solid var(--border);
|
||||
|
||||
+192
-112
@@ -1,84 +1,10 @@
|
||||
/* ==========================================================================
|
||||
Shared card primitives — used by ui/static (Saved Workstreams) and
|
||||
console/static (Saved Coordinators). Single source of truth so the two
|
||||
surfaces don't drift on hover affordance, padding, or typography.
|
||||
Shared saved-list styling. The Delete UX + the pagination control below
|
||||
are shared by ui/static (Saved Workstreams) and console/static (Saved
|
||||
Coordinators); the table styling itself is in the "Saved-list TABLE"
|
||||
section near the bottom of this file.
|
||||
==========================================================================
|
||||
Class names match the original ui/static rules they replaced. Delete-mode
|
||||
selectors live here too so console (Saved Coordinators) and ui/static
|
||||
(Saved Workstreams) share one card + delete affordance.
|
||||
========================================================================== */
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
/* Denser default than the original 200px floor — cards carry a short
|
||||
title + one-line meta, so 160-180px fits 4 across at the 1140px
|
||||
content cap and keeps the row from looking padded-out. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.dashboard-card.is-busy {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
.dashboard-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.dashboard-card:active {
|
||||
background: var(--bg-highlight);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.dashboard-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-card .card-title {
|
||||
font-size: 12px;
|
||||
color: var(--fg-bright);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dashboard-card .card-meta {
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Subtle ws_id badge on each card — also used inline in card-meta. */
|
||||
.card-wsid {
|
||||
font-size: 9px;
|
||||
color: var(--fg-dim);
|
||||
opacity: 0.45;
|
||||
margin-left: 6px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Delete UX — section-level "Delete" toggle, per-card checkboxes, bottom
|
||||
Delete UX — section-level "Delete" toggle, per-row checkboxes, bottom
|
||||
toolbar, and confirmation modal. Moved out of ui/static/style.css when
|
||||
the console grew the same multi-select delete on Saved Coordinators.
|
||||
========================================================================== */
|
||||
@@ -100,27 +26,6 @@
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/* Delete mode */
|
||||
.dashboard-card.ws-delete-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.04);
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected {
|
||||
cursor: default;
|
||||
}
|
||||
.dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode:hover {
|
||||
background: rgba(220, 38, 38, 0.04);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-delete-mode.ws-selected:hover {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
.ws-card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -146,13 +51,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.dashboard-card.ws-selected {
|
||||
border-color: var(--red);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
[data-theme="light"] .dashboard-card.ws-selected {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
.ws-delete-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
@@ -361,11 +259,10 @@
|
||||
for DOM inspection and as a future hook. */
|
||||
|
||||
/* ==========================================================================
|
||||
Pagination — shared by the console (Saved Coordinators + the filtered
|
||||
admin lists) and the server UI (Saved Workstreams). Lives in /shared so
|
||||
the two dashboards can't drift apart; the per-app render helpers
|
||||
(_renderCoordPagination / _renderWsPagination) own the show/hide + page-
|
||||
clamp logic.
|
||||
Pagination — used by the console's filtered admin lists. The saved
|
||||
workstream / coordinator lists scroll-all now (see createSavedTable), so
|
||||
this control is no longer wired to them; it lives in /shared so the
|
||||
remaining admin surfaces can't drift apart.
|
||||
========================================================================== */
|
||||
.pagination {
|
||||
display: flex;
|
||||
@@ -400,3 +297,186 @@
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Saved-list TABLE — reuses base.css .dash-header / .dash-colheaders /
|
||||
.dash-row / .dash-row-main (visual parity with the active dashboard
|
||||
table above it). These add the saved-specific cells, skill chip, header
|
||||
sort affordance, filter toolbar, footer, and delete-mode on rows. Shared
|
||||
by ui/static (Saved Workstreams) and console/static (Saved Coordinators).
|
||||
========================================================================== */
|
||||
|
||||
.saved-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.saved-filter {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
width: 200px;
|
||||
}
|
||||
.saved-filter::placeholder {
|
||||
color: var(--fg-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.saved-filter:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||
}
|
||||
|
||||
/* Sortable column headers — saved table only (active uses .dash-col). */
|
||||
.dash-colheaders .scol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.dash-colheaders .scol.scell-r {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.dash-colheaders .scol:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.dash-colheaders .scol.sorted {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dash-colheaders .scol .caret {
|
||||
font-size: 8px;
|
||||
}
|
||||
/* Inactive columns show a faint neutral caret so every header reads as
|
||||
sortable at rest (not just on hover / the active column). */
|
||||
.dash-colheaders .scol .caret.caret-idle {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Row cells. Saved rows reuse the dash-table chrome but are a terminal,
|
||||
mostly-idle, long list — so the styling that the active (live, short)
|
||||
table tunes for is overridden deliberately here, in one place, rather
|
||||
than fought property-by-property: a legible zebra (the active table's
|
||||
near-zero --row-alt vanishes on a long list) and full-opacity muted text
|
||||
(AA-legible colours, no opacity hacks). Idle-dimming is avoided at the
|
||||
source — renderSessionRow only sets data-state for `error`. */
|
||||
.saved-row .dash-row-main {
|
||||
padding: 8px 16px;
|
||||
/* one CSS var, set per render by createSavedTable (responsive-aware) */
|
||||
grid-template-columns: var(--saved-grid);
|
||||
}
|
||||
.saved-row:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
[data-theme="light"] .saved-row:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.scell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.scell-r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.scell-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: var(--fg-bright);
|
||||
}
|
||||
.scell-name .scell-nm {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.scell-model {
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scell-id {
|
||||
/* --fg-dim alone clears WCAG AA in both themes; the prior opacity:0.5
|
||||
dragged it to ~2.4:1 — and ID is the near-duplicate-name disambiguator,
|
||||
so it has to stay legible. */
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
/* CTX here is a frozen snapshot at last activity, not the live gauge the
|
||||
active table shows — lighter weight so it doesn't read as the same
|
||||
instrument (the column header carries a "context at last activity"
|
||||
tooltip). */
|
||||
.saved-row .dash-cell-ctx {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Skill chip — cool-slate lane, distinct from the amber accent + CTX
|
||||
colours; glyph + >=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);
|
||||
}
|
||||
|
||||
@@ -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 <input> 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) {
|
||||
|
||||
+45
-136
@@ -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 ---
|
||||
|
||||
@@ -203,34 +203,41 @@
|
||||
id="dashboard-saved-ws"
|
||||
aria-label="Saved workstreams"
|
||||
>
|
||||
<div class="dashboard-section-header">
|
||||
<h2 class="dashboard-section-title">Saved Workstreams</h2>
|
||||
<button
|
||||
id="ws-delete-btn"
|
||||
class="ws-delete-btn"
|
||||
onclick="startWsDeleteMode()"
|
||||
title="Delete workstreams"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span> Delete
|
||||
</button>
|
||||
<div class="dash-header">
|
||||
<span class="dash-header-title">SAVED WORKSTREAMS</span>
|
||||
<div class="saved-toolbar">
|
||||
<input
|
||||
id="ws-filter"
|
||||
class="saved-filter"
|
||||
type="search"
|
||||
placeholder="Filter by name…"
|
||||
aria-label="Filter saved workstreams"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="ws-delete-btn"
|
||||
class="ws-delete-btn"
|
||||
onclick="startWsDeleteMode()"
|
||||
title="Delete workstreams"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-cards" id="dashboard-saved-cards"></div>
|
||||
<div class="dash-colheaders" id="ws-saved-colheaders"></div>
|
||||
<div
|
||||
id="ws-pagination"
|
||||
class="pagination"
|
||||
style="display: none"
|
||||
role="navigation"
|
||||
aria-label="Saved workstreams pagination"
|
||||
>
|
||||
<button id="ws-page-prev" type="button" onclick="wsPagePrev()">
|
||||
◄ Prev
|
||||
</button>
|
||||
<span id="ws-page-label" aria-live="polite" aria-atomic="true">
|
||||
</span>
|
||||
<button id="ws-page-next" type="button" onclick="wsPageNext()">
|
||||
Next ►
|
||||
</button>
|
||||
</div>
|
||||
class="dash-table"
|
||||
id="dashboard-saved-cards"
|
||||
role="group"
|
||||
aria-label="Saved workstreams"
|
||||
></div>
|
||||
<div
|
||||
class="saved-footer"
|
||||
id="ws-saved-footer"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<div id="ws-delete-bar" class="ws-delete-bar">
|
||||
<span
|
||||
class="ws-delete-count-label"
|
||||
|
||||
@@ -2257,10 +2257,9 @@ audio.media-player {
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
/* .dashboard-cards / .dashboard-card / .card-title / .card-meta and the
|
||||
delete-mode + modal rules are owned by /shared/cards.css so console
|
||||
(Saved Coordinators) and ui/static (Saved Workstreams) share one source
|
||||
of truth. */
|
||||
/* The saved-list table styling + the multi-select delete/modal rules are
|
||||
owned by /shared/cards.css so console (Saved Coordinators) and ui/static
|
||||
(Saved Workstreams) share one source of truth. */
|
||||
|
||||
/* Server dashboard row — clickable */
|
||||
.dash-row {
|
||||
@@ -2343,7 +2342,6 @@ audio.media-player {
|
||||
.dashboard-content {
|
||||
padding: 24px 12px 16px;
|
||||
}
|
||||
/* .dashboard-cards 1-col at 480px lives in /shared/cards.css */
|
||||
:root {
|
||||
--dash-grid: 50px 1fr 50px;
|
||||
}
|
||||
@@ -2680,7 +2678,6 @@ audio.media-player {
|
||||
.ws-tab .tab-chevron,
|
||||
#new-tab-btn,
|
||||
#split-btn,
|
||||
.dashboard-card,
|
||||
.ts-approval-btn,
|
||||
.ts-approval-feedback,
|
||||
#plan-buttons button,
|
||||
|
||||
Reference in New Issue
Block a user