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:
Patrick Buckley
2026-05-29 17:08:47 -07:00
parent c80354880a
commit 458bc7c4a4
8 changed files with 839 additions and 557 deletions
+96 -176
View File
@@ -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 ---
+31 -38
View File
@@ -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">&#x1f5d1;</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&#x2026;"
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">&#x1f5d1;</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()"
>
&#x25c4; Prev
</button>
<span id="coord-page-label" aria-live="polite" aria-atomic="true">
</span>
<button
id="coord-page-next"
type="button"
onclick="coordPageNext()"
>
Next &#x25ba;
</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"
+2 -4
View File
@@ -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
View File
@@ -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);
}
+437 -59
View File
@@ -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
View File
@@ -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 ---
+33 -26
View File
@@ -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">&#x1f5d1;</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&#x2026;"
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">&#x1f5d1;</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()">
&#x25c4; Prev
</button>
<span id="ws-page-label" aria-live="polite" aria-atomic="true">
</span>
<button id="ws-page-next" type="button" onclick="wsPageNext()">
Next &#x25ba;
</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"
+3 -6
View File
@@ -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,