Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/operator-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ to its recorded agent; see [usage exhaustion and deferred retry](worker.md#usage
| Release a claim | **Direct:** own-claim and guarded override controls | No native fenced Wrighty action | **Direct** | [Recovery paths](claims.md#recovery-paths) |
| Clarify a paused item and preserve its recorded session | **Direct:** edit with explicit queue, hand-back, release, or retain choices | **Direct:** issue content can be clarified; use Wrighty for the claim/session transition | **Direct:** atomic edit/takeover and continuation paths | [Clarify and resume the same session](../workflows.md#clarify-an-item-and-resume-the-same-agent-session) |
| Queue a paused recorded session for a continuous worker | **Direct** | **Guidance:** status comment supplies the Wrighty path | **Direct** | [Clarify and resume the same session](../workflows.md#clarify-an-item-and-resume-the-same-agent-session) |
| Queue, send back, or resume multiple eligible Board cards | **Direct for Local Markdown:** confirmed column actions process the frozen filtered set sequentially, up to 100 at a time | Not available | Repeat the corresponding single-item action | [Web console](web-console.md) |
| Hand a claim back for interactive continuation | **Direct:** produces the fenced resume command and can open its CLI on macOS or native Windows | **Guidance:** status comment supplies the recording-installation path | **Direct:** produces or executes the resume command | [The two-path resume model](worker.md#the-two-path-resume-model) |

## Run and resume agents
Expand Down
17 changes: 15 additions & 2 deletions docs/reference/web-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,28 @@ both directions.

Structured Board filters narrow claimant kind, associated agent, priority, claim ownership,
and update recency. The associated agent is the active claim's agent when present, then the retained
session's agent, then the item's effective agent policy. The filters compose with the instant client-side text search: structured facts are
evaluated by Wrighty, while the search box narrows the returned cards by visible text. Active
session's agent, then the item's effective agent policy. The filters compose with the instant text
search: the browser narrows the visible cards immediately, then Wrighty resolves the same bounded
search on the server so column actions use exactly that displayed result. Active
filters appear as removable chips, column counts and empty states reflect the narrowed result, and
**Clear all** resets the controls. Filter and sort state survives polling and fragment refreshes but
intentionally resets on a full page reload.
The anchored **Filters** panel closes from its top-right close control or an outside click. Its
**Agent** chooser uses the web server's registered agent-adapter inventory, the same supported-agent
source used by creation, editing, Settings, and launch flows.

When at least one shown card in a configured column offers an ordinary workflow action, the
column header offers **Queue all**, **Send back all**, or **Resume all** with the eligible count.
Every bulk action opens a server-generated preview of the current filtered set and requires
confirmation. Wrighty freezes at most 100 canonical item IDs in a five-minute process-local intent,
then rechecks each item and runs the same single-item transition sequentially. Newly eligible items
are not swept in, claimed or stale items are skipped without takeover, and a systemic failure stops
the remaining sequence without rolling back completed changes. A fully successful batch completes
without a notification. If any item is skipped, fails, or is left unprocessed, a warning with the
affected items stays on the Board until dismissed or replaced. **Resume all** queues retained
sessions for a continuous worker; it does not start vendor agents in the web request, choose worker
execution order, or add clarification.

Cards show their last-update time as a local relative value after the page loads; the `datetime`
attribute and hover text retain the absolute UTC instant. Item details show both creation and update
times. Unrecognized agent identifiers now retain their safely encoded configured name for display
Expand Down
43 changes: 37 additions & 6 deletions src/Highbyte.Wrighty.Web/Assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ let workerSummaryRevision = null;
let lastOpenedItem = null;
let authenticationReadyDispatched = false;
let boardControlFocus = null;
let boardSearchTimer = null;
let operationsControlFocus = null;
let settingsScrollAnchor = null;
let hostedLogViews = [];
Expand Down Expand Up @@ -133,6 +134,7 @@ installContextStateUpdates(document);

function applyClientFilter() {
const query = boardSearch.value.trim().toLocaleLowerCase();
const board = document.querySelector("#board-content");
const cards = [...document.querySelectorAll("#board-content .card")];
let visible = 0;

Expand All @@ -148,7 +150,12 @@ function applyClientFilter() {
if (countElement) updateVisibleCount(countElement, group, query, count);
});

const structured = document.querySelector("#board-content")?.dataset.structuredFilterCount;
const batchSearch = (board?.dataset.batchSearch || "").trim().toLocaleLowerCase();
board?.querySelectorAll?.("[data-board-bulk-action]").forEach(action => {
action.hidden = batchSearch !== query;
});

const structured = board?.dataset.structuredFilterCount;
const itemLabel = `${visible} work item${visible === 1 ? "" : "s"}`;
if (query.length === 0) {
filterStatus.textContent = structured === undefined ? "" : `${itemLabel} match the active filters.`;
Expand Down Expand Up @@ -391,6 +398,8 @@ document.addEventListener("htmx:beforeRequest", event => {
}
const card = event.target.closest?.(".card");
if (card) lastOpenedItem = card.dataset.itemId;
if (event.target.closest?.("[data-board-bulk-action]"))
boardControlFocus = captureBoardControlFocus(document);
const scrollAnchor = captureSettingsScrollAnchor(
event.detail.elt || event.target,
event.detail.target
Expand All @@ -402,6 +411,14 @@ document.addEventListener("htmx:beforeRequest", event => {
document.addEventListener("htmx:beforeSwap", event => {
if (contextPanel.beforeSwap(event)) return;
const swapTarget = event.detail.target;
// A confirmation resumes HTMX's captured request. Replacing the region that contains its
// originating control leaves that continuation attached to a disconnected element, and HTMX
// then abandons the confirmed request without issuing it. Keep the frozen preview and its
// control alive until the operator confirms or cancels; the next normal poll catches up.
if (confirmationUi.wouldReplaceTrigger(swapTarget)) {
event.detail.shouldSwap = false;
return;
}
if (swapTarget?.classList?.contains("hosted-worker-log") ||
swapTarget?.id === "operations-content") {
hostedLogViews = captureHostedLogViews(swapTarget);
Expand All @@ -423,8 +440,13 @@ function restoreBoardAfterSwap(target) {
boardRevision = newRevision;
applyClientFilter();
localizeRelativeTimes(board);
restoreBoardControlFocus(document, boardControlFocus);
boardControlFocus = null;
// A batch POST targets the result region before its refresh replaces the whole Board. Keep
// the captured column action through that nested response; restoring it there would focus a
// button that the immediately following Board swap removes.
if (target.id === "board-content") {
restoreBoardControlFocus(document, boardControlFocus);
boardControlFocus = null;
}
}
}

Expand Down Expand Up @@ -551,7 +573,14 @@ document.addEventListener("input", event => {
if (event.target.closest(".edit-form, .create-form")) {
event.target.closest(".edit-form, .create-form").dataset.dirty = "true";
}
if (event.target === boardSearch) applyClientFilter();
if (event.target === boardSearch) {
applyClientFilter();
clearTimeout(boardSearchTimer);
boardSearchTimer = setTimeout(() => {
boardRevision = null;
boardFilters.requestSubmit();
}, 250);
}
});

document.addEventListener("change", event => {
Expand All @@ -561,7 +590,7 @@ document.addEventListener("change", event => {
`[data-sort-direction-for="${CSS.escape(event.target.id)}"]`);
if (button) syncSortDirectionButton(event.target, button);
}
if (event.target.matches("#board-filters select:not([name=scope]), #board-filters input[name], [data-board-column-sort-index]")) {
if (event.target.matches("#board-filters select:not([name=scope]), #board-filters input[name]:not([name=q]), [data-board-column-sort-index]")) {
syncBoardFilterIndicator(boardFilters, boardFilterMenu);
boardControlFocus = captureBoardControlFocus(document);
boardRevision = null;
Expand Down Expand Up @@ -782,7 +811,9 @@ window.addEventListener("resize", () => refreshExpandableValues());
function handleSearchKeydown(event) {
if (event.target === boardSearch && event.key === "Enter") {
event.preventDefault();
applyClientFilter();
clearTimeout(boardSearchTimer);
boardRevision = null;
boardFilters.requestSubmit();
return true;
}
return false;
Expand Down
19 changes: 13 additions & 6 deletions src/Highbyte.Wrighty.Web/Assets/board-controls.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
export function captureBoardControlFocus(doc) {
const active = doc.activeElement;
if (!active?.matches?.("[data-board-column-sort-index]")) return null;
return active.dataset.boardColumnSortIndex || null;
if (active?.matches?.("[data-board-column-sort-index]"))
return `sort:${active.dataset.boardColumnSortIndex}`;
const batch = active?.closest?.("[data-board-bulk-action]");
return batch?.id ? `bulk:${batch.id}` : null;
}

export function restoreBoardControlFocus(doc, columnIndex) {
if (columnIndex === null) return false;
const control = [...doc.querySelectorAll("[data-board-column-sort-index]")]
.find(value => value.dataset.boardColumnSortIndex === columnIndex);
export function restoreBoardControlFocus(doc, key) {
if (key === null) return false;
const bulkId = key.startsWith("bulk:") ? key.slice("bulk:".length) : null;
const columnIndex = bulkId?.match(/-column-(\d+)$/)?.[1];
const control = bulkId !== null
? doc.getElementById(bulkId)?.querySelector("button") ||
doc.querySelector?.(`[data-board-column-index="${columnIndex}"] h2`)
: [...doc.querySelectorAll("[data-board-column-sort-index]")]
.find(value => value.dataset.boardColumnSortIndex === key.replace(/^sort:/, ""));
if (!control) return false;
control.focus();
return true;
Expand Down
20 changes: 19 additions & 1 deletion src/Highbyte.Wrighty.Web/Assets/confirmation-dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ export function installConfirmationDialog({ document, closePanel }) {
const message = document.querySelector("#confirmation-dialog-message");
const cancel = document.querySelector("#confirmation-dialog-cancel");
const accept = document.querySelector("#confirmation-dialog-accept");
let protectedTrigger = null;

// Complete the dialog explicitly instead of depending on form[method=dialog] to copy the
// submit button's value into dialog.returnValue. The confirmed HTMX request resumes from the
// close event, so a browser that closes with an empty return value silently cancels the action.
accept.addEventListener("click", event => {
event.preventDefault();
dialog.close("confirm");
});

function requestConfirmation(
{ title: heading = "Confirm action", message: detail, action = "Continue", tone = "" },
Expand All @@ -19,10 +28,12 @@ export function installConfirmationDialog({ document, closePanel }) {
dialog.returnValue = "";
dialog.dataset.tone = tone === "danger" ? "danger" : "default";
const restoreFocus = typeof trigger?.focus === "function" ? trigger : null;
protectedTrigger = trigger?.isConnected ? trigger : null;

return new Promise(resolve => {
dialog.addEventListener("close", () => {
const confirmed = dialog.returnValue === "confirm";
protectedTrigger = null;
delete dialog.dataset.tone;
if (restoreFocus?.isConnected) restoreFocus.focus();
resolve(confirmed);
Expand Down Expand Up @@ -117,5 +128,12 @@ export function installConfirmationDialog({ document, closePanel }) {
return true;
}

return { handleKeydown, requestConfirmation };
function wouldReplaceTrigger(target) {
return Boolean(
protectedTrigger?.isConnected &&
typeof target?.contains === "function" &&
target.contains(protectedTrigger));
}

return { handleKeydown, requestConfirmation, wouldReplaceTrigger };
}
22 changes: 19 additions & 3 deletions src/Highbyte.Wrighty.Web/Assets/wrighty.css
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,24 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.agents-skill-bulk-actions { justify-content: flex-start; flex-wrap: nowrap; }
.board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(17rem, 1fr); gap: .85rem; overflow-x: auto; align-items: start; padding-bottom: .5rem; }
.column { border: 1px solid var(--line); border-radius: .7rem; background: color-mix(in srgb, var(--surface-2) 65%, transparent); min-height: 10rem; }
.column > header, .archived-group > header { display: flex; justify-content: space-between; align-items: start; gap: .5rem; padding: .8rem .9rem; }
.column-heading { display: grid; gap: .35rem; min-width: 0; }
.column > header { display: grid; gap: .35rem; padding: .8rem .9rem; }
.archived-group > header { display: flex; justify-content: space-between; align-items: start; gap: .5rem; padding: .8rem .9rem; }
.column-title-row { display: flex; align-items: center; gap: .4rem; min-width: 0; }
.column-title-row h2 { flex: 1 1 auto; min-width: 0; }
.column-bulk-action { margin: 0; }
.column-bulk-action button { min-height: 1.8rem; padding: .25rem .55rem; border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--accent); font-size: .72rem; font-weight: 750; white-space: nowrap; }
.column-bulk-eligibility { max-width: 13rem; margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.3; }
.board-batch-result-region:empty { display: none; }
.board-batch-result { display: grid; gap: .45rem; margin-bottom: .8rem; padding: .75rem .9rem; border: 1px solid color-mix(in srgb, var(--warning) 60%, var(--line)); border-radius: .6rem; background: color-mix(in srgb, var(--warning) 10%, var(--surface)); color: var(--warning); }
.board-batch-result > header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
.board-batch-result h2, .board-batch-result p { margin: 0; }
.board-batch-result h2 { font-size: 1rem; }
.board-batch-result header form { margin: 0; }
.board-batch-result header button { min-width: 2rem; min-height: 2rem; padding: .2rem; font-size: 1.1rem; line-height: 1; }
.board-batch-result details { font-size: .82rem; }
.board-batch-result ul { margin-bottom: 0; }
.board-batch-result li button { padding: .1rem .35rem; }
.board-batch-error { margin-bottom: .8rem; }
.column-sort-label { display: block; }
.select-compact, .column-sort { min-height: 1.8rem; max-width: 12rem; padding: .25rem .4rem; font-size: .72rem; font-weight: 600; }
.sort-control { display: flex; align-items: stretch; gap: .25rem; }
Expand Down Expand Up @@ -567,7 +583,7 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.confirmation-dialog-header { justify-content: space-between; }
.confirmation-dialog-header h2 { margin: 0; font-size: 1.15rem; }
.confirmation-dialog-close { min-width: 2.4rem; padding: .35rem; font-size: 1.25rem; line-height: 1; }
.confirmation-dialog-message { margin: 0; color: var(--muted); line-height: 1.5; }
.confirmation-dialog-message { margin: 0; color: var(--muted); line-height: 1.5; white-space: pre-line; }
.confirmation-dialog-actions { justify-content: flex-end; }
.confirmation-dialog[data-tone=danger] #confirmation-dialog-accept { border-color: var(--danger); background: var(--danger); color: #fff; }
.detail-header { display: flex; justify-content: space-between; gap: 1rem; align-items: start; border-bottom: 1px solid var(--line); padding-bottom: .8rem; }
Expand Down
Loading