diff --git a/docs/reference/operator-actions.md b/docs/reference/operator-actions.md index 5fb2351..3a1aa4a 100644 --- a/docs/reference/operator-actions.md +++ b/docs/reference/operator-actions.md @@ -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 diff --git a/docs/reference/web-console.md b/docs/reference/web-console.md index 704566d..e2244ee 100644 --- a/docs/reference/web-console.md +++ b/docs/reference/web-console.md @@ -346,8 +346,9 @@ 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. @@ -355,6 +356,18 @@ The anchored **Filters** panel closes from its top-right close control or an out **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 diff --git a/src/Highbyte.Wrighty.Web/Assets/app.js b/src/Highbyte.Wrighty.Web/Assets/app.js index 06e6510..c38e21d 100644 --- a/src/Highbyte.Wrighty.Web/Assets/app.js +++ b/src/Highbyte.Wrighty.Web/Assets/app.js @@ -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 = []; @@ -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; @@ -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.`; @@ -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 @@ -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); @@ -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; + } } } @@ -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 => { @@ -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; @@ -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; diff --git a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs index d89541c..8cf6a29 100644 --- a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs +++ b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs @@ -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; diff --git a/src/Highbyte.Wrighty.Web/Assets/confirmation-dialog.mjs b/src/Highbyte.Wrighty.Web/Assets/confirmation-dialog.mjs index b07479c..83ab22e 100644 --- a/src/Highbyte.Wrighty.Web/Assets/confirmation-dialog.mjs +++ b/src/Highbyte.Wrighty.Web/Assets/confirmation-dialog.mjs @@ -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 = "" }, @@ -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); @@ -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 }; } diff --git a/src/Highbyte.Wrighty.Web/Assets/wrighty.css b/src/Highbyte.Wrighty.Web/Assets/wrighty.css index 4ad7dce..b1e0bcc 100644 --- a/src/Highbyte.Wrighty.Web/Assets/wrighty.css +++ b/src/Highbyte.Wrighty.Web/Assets/wrighty.css @@ -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; } @@ -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; } diff --git a/src/Highbyte.Wrighty.Web/BoardBatchStore.cs b/src/Highbyte.Wrighty.Web/BoardBatchStore.cs new file mode 100644 index 0000000..67c2f8b --- /dev/null +++ b/src/Highbyte.Wrighty.Web/BoardBatchStore.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Highbyte.Wrighty.Errors; + +namespace Highbyte.Wrighty.Web; + +public enum BoardBatchAction +{ + Queue, + Dequeue, + Resume +} + +public sealed record BoardBatchCandidate( + string Id, + string DisplayId, + string Title); + +public sealed record BoardBatchIntent( + string Id, + BoardBatchAction Action, + string ConfigurationRevision, + DateTimeOffset CreatedAt, + IReadOnlyList Candidates, + int EligibleCount, + int ShownCount); + +public sealed record BoardBatchItemResult( + string Id, + string DisplayId, + bool Succeeded, + bool Skipped, + string? Reason = null, + bool Aborted = false); + +public sealed record BoardBatchResult( + string IntentId, + BoardBatchAction Action, + DateTimeOffset CompletedAt, + IReadOnlyList Items, + string? AbortReason = null) +{ + public int SucceededCount => Items.Count(item => item.Succeeded); + + public int SkippedCount => Items.Count(item => item.Skipped && !item.Aborted); + + public int FailedCount => Items.Count(item => !item.Succeeded && !item.Skipped && !item.Aborted); + + public int AbortedCount => Items.Count(item => item.Aborted); + + public int NotProcessedCount => Items.Count(item => !item.Succeeded); + + public bool HasIssues => AbortReason is not null || NotProcessedCount > 0; +} + +/// +/// Process-local storage for frozen Board batch intents and their idempotent results. Intents +/// contain only bounded display data and canonical item IDs; claim/session credentials and +/// backend payloads never enter this cache. +/// +public sealed class BoardBatchStore( + TimeProvider? timeProvider = null, + TimeSpan? intentLifetime = null, + int maximumEntries = 512) +{ + public const int MaximumCandidates = 100; + private readonly TimeProvider clock = timeProvider ?? TimeProvider.System; + private readonly TimeSpan lifetime = intentLifetime ?? TimeSpan.FromMinutes(5); + private readonly ConcurrentDictionary entries = new(StringComparer.Ordinal); + private readonly object latestLock = new(); + private BoardBatchResult? latestResult; + + public BoardBatchIntent Create( + BoardBatchAction action, + string? configurationRevision, + IReadOnlyList candidates, + int eligibleCount, + int shownCount) + { + Purge(); + var frozen = candidates + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .Take(MaximumCandidates) + .ToArray(); + var intent = new BoardBatchIntent( + RandomNumberGenerator.GetHexString(32).ToLowerInvariant(), + action, + configurationRevision ?? string.Empty, + clock.GetUtcNow(), + frozen, + eligibleCount, + shownCount); + entries[intent.Id] = new Entry(intent); + EnforceBound(); + return intent; + } + + public BoardBatchResult? LatestResult + { + get + { + lock (latestLock) + return latestResult; + } + } + + public async Task ExecuteAsync( + string? intentId, + string? configurationRevision, + Func> execute) + { + var entry = Find(intentId); + await entry.Gate.WaitAsync(CancellationToken.None); + try + { + if (entry.Result is { } completed) + return completed; + if (clock.GetUtcNow() - entry.Intent.CreatedAt > lifetime) + { + entries.TryRemove(entry.Intent.Id, out _); + throw InvalidIntent( + "BOARD_BATCH_EXPIRED", + "This batch preview expired. Refresh the Board and review the current items again."); + } + if (!string.Equals( + entry.Intent.ConfigurationRevision, + configurationRevision ?? string.Empty, + StringComparison.Ordinal)) + { + throw InvalidIntent( + "BOARD_BATCH_CONFIG_CHANGED", + "Wrighty's configuration changed after this preview. Refresh the Board and review the batch again."); + } + + var result = await execute(entry.Intent); + entry.Result = result; + lock (latestLock) + latestResult = result.HasIssues ? result : null; + return result; + } + finally + { + entry.Gate.Release(); + } + } + + public bool Dismiss(string? intentId) + { + if (string.IsNullOrWhiteSpace(intentId)) + return false; + lock (latestLock) + { + if (!string.Equals(latestResult?.IntentId, intentId, StringComparison.Ordinal)) + return false; + latestResult = null; + return true; + } + } + + private Entry Find(string? intentId) + { + if (string.IsNullOrWhiteSpace(intentId) || intentId.Length > 128 || + !entries.TryGetValue(intentId, out var entry)) + { + throw InvalidIntent( + "BOARD_BATCH_UNKNOWN", + "This batch preview is no longer available. Refresh the Board and review the current items again."); + } + Purge(); + return entry; + } + + private void Purge() + { + var cutoff = clock.GetUtcNow() - lifetime; + foreach (var pair in entries) + { + if (pair.Value.Result is null && pair.Value.Intent.CreatedAt < cutoff) + entries.TryRemove(pair.Key, out _); + } + } + + private void EnforceBound() + { + var overflow = entries.Count - maximumEntries; + if (overflow <= 0) + return; + foreach (var entry in entries.Values + .OrderBy(value => value.Result is null ? 0 : 1) + .ThenBy(value => value.Intent.CreatedAt) + .Take(overflow)) + { + entries.TryRemove(entry.Intent.Id, out _); + } + } + + private static TrackerException InvalidIntent(string code, string message) => + new(code, message, 6); + + private sealed class Entry(BoardBatchIntent intent) + { + public BoardBatchIntent Intent { get; } = intent; + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public BoardBatchResult? Result { get; set; } + } +} diff --git a/src/Highbyte.Wrighty.Web/ItemOrganization.cs b/src/Highbyte.Wrighty.Web/ItemOrganization.cs index 61aeff7..862c5dc 100644 --- a/src/Highbyte.Wrighty.Web/ItemOrganization.cs +++ b/src/Highbyte.Wrighty.Web/ItemOrganization.cs @@ -28,6 +28,7 @@ public readonly record struct ItemSort(ItemSortField Field, bool Descending) public sealed class BoardListInput { + public string? Q { get; set; } public string? Scope { get; set; } public string? Sort { get; set; } public string[]? ColumnSort { get; set; } @@ -39,6 +40,7 @@ public sealed class BoardListInput } public sealed record BoardListQuery( + string? Search, ItemSort Sort, IReadOnlyDictionary ColumnSorts, IReadOnlySet ClaimKinds, @@ -70,6 +72,7 @@ public static BoardListQuery Parse(BoardListInput input) if (updated is not ("today" or "7d" or "30d")) updated = null; return new BoardListQuery( + ParseSearch(input.Q), ParseSort(input.Sort), columns, Known(input.ClaimKind, ["unclaimed", "human", "agent", "automation", "unknown"]), @@ -82,11 +85,15 @@ public static BoardListQuery Parse(BoardListInput input) public ItemSort SortForColumn(int index) => ColumnSorts.TryGetValue(index, out var value) ? value : Sort; - public bool HasFilters => ClaimKinds.Count > 0 || Agents.Count > 0 || Priorities.Count > 0 || + public bool HasStructuredFilters => ClaimKinds.Count > 0 || Agents.Count > 0 || Priorities.Count > 0 || ClaimStates.Count > 0 || UpdatedWithin is not null; + public bool HasFilters => Search is not null || HasStructuredFilters; + public bool Matches(BoardCardModel card, DateTimeOffset now) { + if (Search is { } search && !SearchText(card).Contains(search, StringComparison.OrdinalIgnoreCase)) + return false; if (ClaimKinds.Count > 0 && !ClaimKinds.Contains(ClaimKind(card))) return false; if (Agents.Count > 0 && (card.AgentKey is null || !Agents.Contains(card.AgentKey))) return false; if (Priorities.Count > 0 && @@ -101,7 +108,7 @@ public string RevisionKey get { var builder = new StringBuilder(); - builder.Append(Sort.Key); + builder.Append("search:").Append(Search ?? string.Empty).Append('\n').Append(Sort.Key); Append(builder, "columns", ColumnSorts.OrderBy(value => value.Key) .Select(value => $"{value.Key}:{value.Value.Key}")); Append(builder, "claim-kind", ClaimKinds); @@ -154,6 +161,24 @@ private static HashSet Values(IReadOnlyList? values, int maximum internal static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + private static string? ParseSearch(string? value) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + return normalized is { Length: <= 200 } && !normalized.Any(char.IsControl) + ? normalized + : null; + } + + private static string SearchText(BoardCardModel card) => string.Join(' ', + card.DisplayId, + card.Title, + card.Status, + card.Priority, + card.ClaimantKindLabel, + card.AgentLabel, + OperationalStatusDisplay.Label(card.OperationalStatus, card.AgentLabel), + card.ProviderBlock?.Reason); + private static string ClaimKind(BoardCardModel card) => card.ClaimState switch { ClaimOwnershipState.Unclaimed => "unclaimed", diff --git a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml index 97bdbb5..0c6c6e5 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml @@ -86,7 +86,7 @@
- +
+ @if (bulkAction is not null && bulkAction.EligibleCount != bulkAction.ShownCount) + { +

@bulkAction.Description

+ } + - - @column.Cards.Count + +
@if (column.Cards.Count == 0) diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchError.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchError.cshtml new file mode 100644 index 0000000..747896a --- /dev/null +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchError.cshtml @@ -0,0 +1,4 @@ +@model WebErrorModel + diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchResult.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchResult.cshtml new file mode 100644 index 0000000..a328bca --- /dev/null +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_BoardBatchResult.cshtml @@ -0,0 +1,45 @@ +@model BoardBatchResult +@{ + var action = Model.Action switch + { + BoardBatchAction.Queue => "Queue all", + BoardBatchAction.Dequeue => "Send back all", + _ => "Resume all" + }; + var aborted = Model.AbortedCount > 0 + ? $", {Model.AbortedCount} not processed" + : string.Empty; +} + diff --git a/src/Highbyte.Wrighty.Web/WebApplicationState.cs b/src/Highbyte.Wrighty.Web/WebApplicationState.cs index 0eaccbf..6b9e2d7 100644 --- a/src/Highbyte.Wrighty.Web/WebApplicationState.cs +++ b/src/Highbyte.Wrighty.Web/WebApplicationState.cs @@ -40,6 +40,8 @@ public sealed class WebApplicationState( ? "GITHUB PROJECT" : "LOCAL MARKDOWN"; public string? ActiveConfigurationRevision => ActiveConfiguration.Revision; + public string? ConfigurationRevision => + requestConfiguration.Value?.Revision ?? ActiveConfiguration.Revision; public string? Token { get; } = token; public bool TokenAuthenticationRequired { get; } = tokenAuthenticationRequired; public string WorkspacePath { get; } = ResolveWorkspacePath(config, workingDirectory); @@ -47,6 +49,7 @@ public sealed class WebApplicationState( DisplayWorkspacePath(ResolveWorkspacePath(config, workingDirectory)); public string LocalHostName { get; } = SafeHostName(localHostName); public string ClaimantId { get; } = $"web:{Guid.NewGuid():N}"; + public BoardBatchStore BoardBatches { get; } = new(); public AgentExecutionContext ClaimantContext => new(null, null, AgentContextSource.ExplicitOption, ClaimantKind: ClaimantKind.Human, ClaimantId: ClaimantId); diff --git a/src/Highbyte.Wrighty.Web/WebViewModels.cs b/src/Highbyte.Wrighty.Web/WebViewModels.cs index e93339e..2d39b77 100644 --- a/src/Highbyte.Wrighty.Web/WebViewModels.cs +++ b/src/Highbyte.Wrighty.Web/WebViewModels.cs @@ -19,7 +19,8 @@ public sealed record BoardPageModel( string? ErrorCode = null, string? ErrorMessage = null, IReadOnlyList? ProviderCapacity = null, - BoardListQuery? Query = null) + BoardListQuery? Query = null, + BoardBatchResult? BatchResult = null) { public IReadOnlyList EffectiveProviderCapacity => ProviderCapacity ?? []; @@ -31,11 +32,23 @@ public sealed record BoardColumnModel( string Name, IReadOnlyList Cards, int Index = 0, - ItemSort? Sort = null) + ItemSort? Sort = null, + BoardBulkActionView? BulkAction = null) { public ItemSort EffectiveSort => Sort ?? ItemSort.Default; } +public sealed record BoardBulkActionView( + string Id, + string IntentId, + string Label, + string Description, + string ConfirmTitle, + string ConfirmMessage, + string ConfirmAction, + int EligibleCount, + int ShownCount); + public sealed record BoardCardModel( string Id, string DisplayId, diff --git a/src/Highbyte.Wrighty.Web/WrightyWebServer.cs b/src/Highbyte.Wrighty.Web/WrightyWebServer.cs index bad5b27..5c598e2 100644 --- a/src/Highbyte.Wrighty.Web/WrightyWebServer.cs +++ b/src/Highbyte.Wrighty.Web/WrightyWebServer.cs @@ -433,6 +433,8 @@ internal static bool IsSharedMutation(string? handler) => string.Equals(handler, "UpdateAgentSkill", StringComparison.OrdinalIgnoreCase) || string.Equals(handler, "UninstallSkill", StringComparison.OrdinalIgnoreCase) || string.Equals(handler, "MaintainAllSkills", StringComparison.OrdinalIgnoreCase) || + // Dismissing a retained Board result changes only process-local presentation state. + string.Equals(handler, "DismissBoardBatch", StringComparison.OrdinalIgnoreCase) || // Opening a retained vendor session operates on Wrighty's local claim/session control // plane, not backend-owned item content. Operations offers these on both Local Markdown // and GitHub, so they are shared even though their target is one item. diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/BoardBatchStoreTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/BoardBatchStoreTests.cs new file mode 100644 index 0000000..05daa77 --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Web/BoardBatchStoreTests.cs @@ -0,0 +1,186 @@ +using Highbyte.Wrighty.Errors; +using Highbyte.Wrighty.Web; + +namespace Highbyte.Wrighty.UnitTests.Web; + +public sealed class BoardBatchStoreTests +{ + [Fact] + public void Intent_freezes_canonical_candidates_and_caps_the_batch_at_one_hundred() + { + var store = new BoardBatchStore(); + var candidates = Enumerable.Range(1, 125) + .Reverse() + .Select(index => new BoardBatchCandidate( + $"local:{index:D3}", + $"#{index}", + $"Item {index}")) + .ToArray(); + + var intent = store.Create( + BoardBatchAction.Queue, + "revision-a", + candidates, + candidates.Length, + shownCount: 130); + + Assert.Equal(100, intent.Candidates.Count); + Assert.Equal("local:001", intent.Candidates[0].Id); + Assert.Equal("local:100", intent.Candidates[^1].Id); + Assert.Equal(125, intent.EligibleCount); + Assert.Equal(130, intent.ShownCount); + Assert.Matches("^[0-9a-f]{32}$", intent.Id); + } + + [Fact] + public async Task Duplicate_confirmation_returns_the_recorded_result_without_executing_twice() + { + var store = new BoardBatchStore(); + var intent = store.Create( + BoardBatchAction.Resume, + "revision-a", + [new BoardBatchCandidate("local:1", "#1", "One")], + 1, + 1); + var calls = 0; + Task Execute(BoardBatchIntent value) + { + calls++; + return Task.FromResult(new BoardBatchResult( + value.Id, + value.Action, + DateTimeOffset.UtcNow, + [new BoardBatchItemResult("local:1", "#1", true, false)])); + } + + var first = await store.ExecuteAsync(intent.Id, "revision-a", Execute); + var duplicate = await store.ExecuteAsync(intent.Id, "revision-a", Execute); + + Assert.Same(first, duplicate); + Assert.Equal(1, calls); + Assert.Null(store.LatestResult); + } + + [Fact] + public async Task Intent_rejects_expiry_tampering_and_configuration_drift() + { + var clock = new AdjustableTimeProvider( + new DateTimeOffset(2026, 8, 26, 10, 0, 0, TimeSpan.Zero)); + var store = new BoardBatchStore(clock); + var expired = store.Create( + BoardBatchAction.Queue, + "revision-a", + [new BoardBatchCandidate("local:1", "#1", "One")], + 1, + 1); + clock.Advance(TimeSpan.FromMinutes(6)); + + var expiry = await Assert.ThrowsAsync(() => + store.ExecuteAsync(expired.Id, "revision-a", UnexpectedExecution)); + Assert.Equal("BOARD_BATCH_EXPIRED", expiry.Code); + + var current = store.Create( + BoardBatchAction.Queue, + "revision-a", + [new BoardBatchCandidate("local:1", "#1", "One")], + 1, + 1); + var drift = await Assert.ThrowsAsync(() => + store.ExecuteAsync(current.Id, "revision-b", UnexpectedExecution)); + Assert.Equal("BOARD_BATCH_CONFIG_CHANGED", drift.Code); + + var unknown = await Assert.ThrowsAsync(() => + store.ExecuteAsync("not-an-intent", "revision-a", UnexpectedExecution)); + Assert.Equal("BOARD_BATCH_UNKNOWN", unknown.Code); + } + + [Fact] + public async Task Dismiss_only_removes_the_matching_latest_result() + { + var store = new BoardBatchStore(); + var intent = store.Create( + BoardBatchAction.Dequeue, + "revision-a", + [new BoardBatchCandidate("local:1", "#1", "One")], + 1, + 1); + await store.ExecuteAsync(intent.Id, "revision-a", value => Task.FromResult( + new BoardBatchResult( + value.Id, + value.Action, + DateTimeOffset.UtcNow, + [new BoardBatchItemResult("local:1", "#1", false, true, "Changed")]))); + + Assert.False(store.Dismiss("another-intent")); + Assert.NotNull(store.LatestResult); + Assert.True(store.Dismiss(intent.Id)); + Assert.Null(store.LatestResult); + } + + [Fact] + public async Task Successful_completion_clears_a_previous_warning() + { + var store = new BoardBatchStore(); + var warningIntent = store.Create( + BoardBatchAction.Queue, + "revision-a", + [new BoardBatchCandidate("local:1", "#1", "One")], + 1, + 1); + await store.ExecuteAsync(warningIntent.Id, "revision-a", value => Task.FromResult( + new BoardBatchResult( + value.Id, + value.Action, + DateTimeOffset.UtcNow, + [new BoardBatchItemResult("local:1", "#1", false, true, "Changed")]))); + Assert.NotNull(store.LatestResult); + + var successfulIntent = store.Create( + BoardBatchAction.Queue, + "revision-a", + [new BoardBatchCandidate("local:2", "#2", "Two")], + 1, + 1); + await store.ExecuteAsync(successfulIntent.Id, "revision-a", value => Task.FromResult( + new BoardBatchResult( + value.Id, + value.Action, + DateTimeOffset.UtcNow, + [new BoardBatchItemResult("local:2", "#2", true, false)]))); + + Assert.Null(store.LatestResult); + } + + [Fact] + public void Result_counts_partial_outcomes_without_mislabeling_aborted_items() + { + var result = new BoardBatchResult( + "intent", + BoardBatchAction.Queue, + DateTimeOffset.UtcNow, + [ + new BoardBatchItemResult("1", "#1", true, false), + new BoardBatchItemResult("2", "#2", false, true, "Changed"), + new BoardBatchItemResult("3", "#3", false, false, "Backend error"), + new BoardBatchItemResult("4", "#4", false, false, "Not processed", true) + ], + "Stopped"); + + Assert.Equal(1, result.SucceededCount); + Assert.Equal(1, result.SkippedCount); + Assert.Equal(1, result.FailedCount); + Assert.Equal(1, result.AbortedCount); + Assert.Equal(3, result.NotProcessedCount); + Assert.True(result.HasIssues); + } + + private static Task UnexpectedExecution(BoardBatchIntent _) => + throw new Xunit.Sdk.XunitException("The rejected intent must not execute."); + + private sealed class AdjustableTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + + public void Advance(TimeSpan value) => now += value; + } +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/ItemOrganizationTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/ItemOrganizationTests.cs index 532ee6e..a15a54b 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Web/ItemOrganizationTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Web/ItemOrganizationTests.cs @@ -66,6 +66,22 @@ public void Board_query_filters_claim_agent_priority_state_and_recent_update() "agent", "codex", null), Now)); } + [Fact] + public void Board_search_is_server_resolved_and_bounded_for_batch_scope() + { + var query = BoardListQuery.Parse(new BoardListInput { Q = " LOCAL:1 " }); + + Assert.True(query.HasFilters); + Assert.False(query.HasStructuredFilters); + Assert.Equal("LOCAL:1", query.Search); + Assert.True(query.Matches(Card("local:1", "P1"), Now)); + Assert.False(query.Matches(Card("local:2", "P1"), Now)); + + var oversized = BoardListQuery.Parse(new BoardListInput { Q = new string('x', 201) }); + Assert.Null(oversized.Search); + Assert.False(oversized.HasFilters); + } + [Fact] public void Timestamp_sort_keeps_missing_values_last_in_both_directions() { diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/SharedMutationHandlerTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/SharedMutationHandlerTests.cs index 604542a..80d03f1 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Web/SharedMutationHandlerTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Web/SharedMutationHandlerTests.cs @@ -27,7 +27,7 @@ public sealed class SharedMutationHandlerTests // Queue and panel actions belong to the Local Markdown item surface. Operations' direct // OpenSession actions are shared instead: they change Wrighty's claim/session metadata, // not backend-owned item content. - "ResumeSession", "HoldSession", "QueueForWorker", + "ResumeSession", "HoldSession", "QueueForWorker", "ExecuteBoardBatch", "LaunchAgentCli", "LaunchAgentDesktop" }; @@ -80,6 +80,7 @@ public void Every_post_handler_is_classified_as_shared_or_as_a_work_item_edit(st [InlineData("UpdateAgentSkill")] [InlineData("UninstallSkill")] [InlineData("MaintainAllSkills")] + [InlineData("DismissBoardBatch")] [InlineData("OpenSessionCli")] [InlineData("OpenSessionDesktop")] public void Machine_local_and_provider_posts_survive_a_backend_that_owns_its_items(string handler) => diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs index 149959a..ca110cf 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs @@ -117,7 +117,7 @@ public async Task Server_serves_public_shell_but_requires_launch_token_for_track Assert.True( shell.IndexOf("id=\"item-panel\"", StringComparison.Ordinal) < shell.IndexOf("id=\"confirmation-dialog\"", StringComparison.Ordinal)); - Assert.DoesNotContain("name=\"q\"", shell); + Assert.Contains("id=\"board-search\" name=\"q\"", shell); Assert.DoesNotContain(">Load scope<", shell); var unauthorized = await client.GetAsync($"{host.Origin}/?handler=Board"); @@ -169,10 +169,10 @@ public async Task Server_serves_public_shell_but_requires_launch_token_for_track var agentBoardHtml = await (await client.SendAsync(agentBoardRequest)).Content.ReadAsStringAsync(); Assert.Contains("Hostile item", agentBoardHtml); - using var ignoredQueryRequest = new HttpRequestMessage(HttpMethod.Get, $"{host.Origin}/?handler=Board&q=does-not-match"); - ignoredQueryRequest.Headers.Add(WrightyWebServer.TokenHeader, host.Token); - var ignoredQuery = await client.SendAsync(ignoredQueryRequest); - Assert.Contains("Hostile item", await ignoredQuery.Content.ReadAsStringAsync()); + using var searchedBoardRequest = new HttpRequestMessage(HttpMethod.Get, $"{host.Origin}/?handler=Board&q=does-not-match"); + searchedBoardRequest.Headers.Add(WrightyWebServer.TokenHeader, host.Token); + var searchedBoard = await client.SendAsync(searchedBoardRequest); + Assert.DoesNotContain("Hostile item", await searchedBoard.Content.ReadAsStringAsync()); using var unchangedRequest = new HttpRequestMessage(HttpMethod.Get, $"{host.Origin}/?handler=Board"); unchangedRequest.Headers.Add(WrightyWebServer.TokenHeader, host.Token); @@ -2010,6 +2010,252 @@ public async Task Board_queue_button_moves_a_backlog_item_into_the_worker_queue( await host.Stop(); } + [Fact] + public async Task Board_bulk_queue_freezes_filtered_candidates_revalidates_and_is_idempotent() + { + var host = await StartServer(openBrowser: false, pickFrom: "Worker queue"); + using var client = new HttpClient(); + using var createRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Create"); + var createForm = await (await client.SendAsync(createRequest)).Content.ReadAsStringAsync(); + using var created = await PostForm(client, host, "Create", new() + { + ["title"] = "Web batch candidate", + ["body"] = "Body", + ["status"] = "Todo", + ["creationAttemptId"] = HiddenValue(createForm, "creationAttemptId") + }); + var newId = await StoredItemId("Web batch candidate"); + var existingId = await StoredItemId("Web claim item"); + + using var previewRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Web"); + var preview = await (await client.SendAsync(previewRequest)).Content.ReadAsStringAsync(); + Assert.Contains("data-batch-search=\"Web\"", preview); + Assert.Contains("id=\"board-bulk-queue-column-0\"", preview); + Assert.Contains("Queue all (2)", preview); + Assert.Contains("Frozen preview (2 of 2 eligible; 2 shown)", preview); + var bulkActionIndex = preview.IndexOf("id=\"board-bulk-queue-column-0\"", StringComparison.Ordinal); + var countIndex = preview.IndexOf("class=\"column-count", bulkActionIndex, StringComparison.Ordinal); + var titleRowEndIndex = preview.IndexOf("
", bulkActionIndex, StringComparison.Ordinal); + Assert.True(bulkActionIndex < countIndex && countIndex < titleRowEndIndex); + var intentId = HiddenValue(preview, "intentId"); + + // A newly matching card is not part of the frozen preview. + using var lateCreateRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Create"); + var lateCreateForm = await (await client.SendAsync(lateCreateRequest)).Content.ReadAsStringAsync(); + using var lateCreated = await PostForm(client, host, "Create", new() + { + ["title"] = "Web late arrival", + ["body"] = "Body", + ["status"] = "Todo", + ["creationAttemptId"] = HiddenValue(lateCreateForm, "creationAttemptId") + }); + var lateId = await StoredItemId("Web late arrival"); + + // A frozen candidate that becomes claimed is skipped rather than taken over. + using var claimed = await PostForm(client, host, "Claim", new() { ["id"] = existingId }); + Assert.Equal(HttpStatusCode.OK, claimed.StatusCode); + using var executed = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = intentId }, + preview); + Assert.Equal(HttpStatusCode.NoContent, executed.StatusCode); + Assert.Equal("wrighty:refresh", Assert.Single(executed.Headers.GetValues("HX-Trigger"))); + + // A repeated confirmation returns the retained result and does not reapply the batch. + using var duplicate = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = intentId }, + preview); + Assert.Equal(HttpStatusCode.NoContent, duplicate.StatusCode); + + using var resultRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Web"); + var result = await (await client.SendAsync(resultRequest)).Content.ReadAsStringAsync(); + Assert.Contains("Queue all warning", result); + Assert.Contains("role=\"alert\"", result); + Assert.Contains("1 item could not be processed.", result); + Assert.Contains("1 completed successfully; 1 skipped", result); + Assert.DoesNotContain("@if", result); + Assert.Contains("No longer eligible for this action.", result); + Assert.Contains("Worker queue", await ItemHtml(client, host, newId)); + Assert.Contains("Todo", await ItemHtml(client, host, existingId)); + Assert.Contains("Todo", await ItemHtml(client, host, lateId)); + + using var dismissed = await PostFormWithToken( + client, + host, + "DismissBoardBatch", + new() { ["intentId"] = intentId }, + result); + Assert.Equal(HttpStatusCode.NoContent, dismissed.StatusCode); + using var afterDismissRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Web"); + var afterDismiss = await (await client.SendAsync(afterDismissRequest)).Content.ReadAsStringAsync(); + Assert.DoesNotContain("Queue all warning", afterDismiss); + await host.Stop(); + } + + [Fact] + public async Task Board_bulk_action_supports_one_candidate_and_labels_the_hundred_item_limit() + { + var host = await StartServer(openBrowser: false, pickFrom: "Worker queue"); + using var client = new HttpClient(); + using var singleRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Web%20claim%20item"); + var single = await (await client.SendAsync(singleRequest)).Content.ReadAsStringAsync(); + var singleForm = BatchFormMarkup(single, "board-bulk-queue-column-0"); + Assert.Contains("Queue all (1)", singleForm); + Assert.Contains("Frozen preview (1 of 1 eligible; 1 shown)", singleForm); + + using var noneRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Copilot%20claim"); + var none = await (await client.SendAsync(noneRequest)).Content.ReadAsStringAsync(); + Assert.DoesNotContain("data-board-bulk-action=\"queue\"", none); + + var (config, backend, _) = await StoredBackend(); + foreach (var index in Enumerable.Range(1, 101)) + { + await backend.CreateAsync( + config, + new CreateWorkItemOperation( + new CreateWorkItemRequest( + $"Bulk limit {index:D3}", + "Body", + "Todo", + "P2"), + false), + CancellationToken.None); + } + + using var limitedRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board&q=Bulk%20limit"); + var limited = await (await client.SendAsync(limitedRequest)).Content.ReadAsStringAsync(); + var form = BatchFormMarkup(limited, "board-bulk-queue-column-0"); + Assert.Contains("Process first 100 of 101", form); + Assert.Contains("Frozen preview (100 of 101 eligible; 101 shown)", form); + Assert.Contains("and 80 more", form); + Assert.Contains("Run the action again for the remainder", form); + await host.Stop(); + } + + [Fact] + public async Task Board_bulk_actions_can_repeat_queue_and_send_back_in_both_directions() + { + var host = await StartServer(openBrowser: false, pickFrom: "Worker queue"); + using var client = new HttpClient(); + var firstId = await StoredItemId("Web claim item"); + var secondId = await StoredItemId("Provider blocked ready item"); + + using var queuePreviewRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Board"); + var queuePreview = await (await client.SendAsync(queuePreviewRequest)).Content.ReadAsStringAsync(); + var queueForm = BatchFormMarkup(queuePreview, "board-bulk-queue-column-0"); + using var queued = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = HiddenValue(queueForm, "intentId") }, + queueForm); + Assert.Equal(HttpStatusCode.NoContent, queued.StatusCode); + + using var sendBackPreviewRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Board"); + var sendBackPreview = await (await client.SendAsync(sendBackPreviewRequest)).Content.ReadAsStringAsync(); + var sendBackForm = BatchFormMarkup( + sendBackPreview, + "board-bulk-dequeue-column-1"); + Assert.Contains("Send back all", sendBackForm); + Assert.Contains("from Worker queue to Todo", sendBackForm); + using var sentBack = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = HiddenValue(sendBackForm, "intentId") }, + sendBackForm); + Assert.Equal(HttpStatusCode.NoContent, sentBack.StatusCode); + + Assert.Contains("Todo", await ItemHtml(client, host, firstId)); + Assert.Contains("Todo", await ItemHtml(client, host, secondId)); + using var resultRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Board"); + var result = await (await client.SendAsync(resultRequest)).Content.ReadAsStringAsync(); + Assert.DoesNotContain("Send back all warning", result); + Assert.DoesNotContain("class=\"board-batch-result warning\"", result); + + // A retained result adds another intentId field to the Board. Repeating from that Board + // must still submit the new column action's own intent rather than the prior result's ID. + var queueAgainForm = BatchFormMarkup(result, "board-bulk-queue-column-0"); + using var queuedAgain = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = HiddenValue(queueAgainForm, "intentId") }, + queueAgainForm); + Assert.Equal(HttpStatusCode.NoContent, queuedAgain.StatusCode); + Assert.Contains("Worker queue", await ItemHtml(client, host, firstId)); + Assert.Contains("Worker queue", await ItemHtml(client, host, secondId)); + + using var secondSendBackPreviewRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Board"); + var secondSendBackPreview = await ( + await client.SendAsync(secondSendBackPreviewRequest)).Content.ReadAsStringAsync(); + var secondSendBackForm = BatchFormMarkup( + secondSendBackPreview, + "board-bulk-dequeue-column-1"); + using var sentBackAgain = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = HiddenValue(secondSendBackForm, "intentId") }, + secondSendBackForm); + Assert.Equal(HttpStatusCode.NoContent, sentBackAgain.StatusCode); + Assert.Contains("Todo", await ItemHtml(client, host, firstId)); + Assert.Contains("Todo", await ItemHtml(client, host, secondId)); + await host.Stop(); + } + + [Fact] + public async Task Board_bulk_resume_queues_complete_local_sessions_without_launching_agents() + { + var host = await StartServer(openBrowser: false, releaseSeededClaim: true); + using var client = new HttpClient(); + var secondId = await CreatePausedSessionAsync("Second paused session", "second-session"); + + using var previewRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Board"); + var preview = await (await client.SendAsync(previewRequest)).Content.ReadAsStringAsync(); + var resumeForm = BatchFormMarkup(preview, "board-bulk-resume-column-2"); + Assert.Contains("Resume all (2)", resumeForm); + Assert.Contains("Wrighty will not add clarification", resumeForm); + Assert.Contains("provider subscription capacity", resumeForm); + + using var resumed = await PostFormWithToken( + client, + host, + "ExecuteBoardBatch", + new() { ["intentId"] = HiddenValue(resumeForm, "intentId") }, + resumeForm); + Assert.Equal(HttpStatusCode.NoContent, resumed.StatusCode); + Assert.Equal(DispatchStates.Queued, (await StoredState()).Item.DispatchState); + var (config, backend, second) = await StoredBackend(secondId); + var secondState = await backend.GetOperationalAsync(config, second, CancellationToken.None); + Assert.Equal(DispatchStates.Queued, secondState?.Item.DispatchState); + + using var resultRequest = AuthenticatedGet(host, $"{host.Origin}/?handler=Board"); + var result = await (await client.SendAsync(resultRequest)).Content.ReadAsStringAsync(); + Assert.DoesNotContain("Resume all warning", result); + Assert.DoesNotContain("class=\"board-batch-result warning\"", result); + await host.Stop(); + } + [Fact] public async Task Dropping_a_card_on_the_queue_column_moves_and_authorizes_it() { @@ -2269,6 +2515,15 @@ private static string CardMarkup(string board, string id) return next > from ? board[from..next] : board[from..]; } + private static string BatchFormMarkup(string board, string id) + { + var start = board.IndexOf($"
= 0, $"the board must contain the batch form '{id}'"); + var end = board.IndexOf("
", start, StringComparison.Ordinal); + Assert.True(end > start, $"the batch form '{id}' must be complete"); + return board[start..(end + "".Length)]; + } + /// One Operations row, isolating its actions from the other operational items. private static string OperationsRowMarkup(string operations, string id) { @@ -3182,6 +3437,71 @@ private async Task StoredItemId(string title) return Assert.Single(items, item => item.Title == title).Id.Value; } + private async Task CreatePausedSessionAsync(string title, string sessionId) + { + var (config, backend, _) = await StoredBackend(); + var created = await backend.CreateAsync( + config, + new CreateWorkItemOperation( + new CreateWorkItemRequest( + title, + "Body", + "In Progress", + "P2", + AutomaticExecutionAllowed: true, + AgentPolicy: "codex"), + false), + CancellationToken.None); + var context = new AgentExecutionContext( + "codex", + sessionId, + AgentContextSource.ExplicitOption, + ClaimantKind: ClaimantKind.Agent, + ClaimantId: $"agent:{sessionId}"); + var claim = await backend.TryClaimAsync( + config, + created.Id, + context, + CancellationToken.None); + var handle = new ClaimHandle(context, claim.ClaimToken); + await backend.RenewClaimAsync( + config, + created.Id, + handle, + directory, + sessionId, + CancellationToken.None); + await backend.UpdateAsync( + config, + created.Id, + new UpdateWorkItemOperation( + new WorkItemPatch( + OptionalValue.Unspecified, + OptionalValue.Unspecified, + OptionalValue.Unspecified, + OptionalValue.Unspecified, + DispatchState: OptionalValue.From(DispatchStates.NeedsAttention)), + false, + ClaimHandle: handle), + CancellationToken.None); + await backend.RecordRunOutcomeAsync( + config, + created.Id, + RunOutcome.Succeeded, + "Paused for a decision.", + DateTimeOffset.UtcNow, + null, + CancellationToken.None); + await backend.ReleaseAsync( + config, + created.Id, + handle, + false, + DispatchStateOnRelease.Preserve, + CancellationToken.None); + return created.Id.Value; + } + private async Task<(TrackerConfig Config, LocalMarkdownTrackerBackend Backend, WorkItemId Id)> StoredBackend(string id = "local:1") { @@ -5570,6 +5890,7 @@ public async Task Embedded_first_party_assets_are_served_and_unknown_assets_are_ Assert.Contains("highlightElement", applicationScript); Assert.Contains("htmx:afterSwap", applicationScript); Assert.Contains("confirmationUi.handleKeydown(event)", applicationScript); + Assert.Contains("confirmationUi.wouldReplaceTrigger(swapTarget)", applicationScript); Assert.Contains("dataset.confirmMessage", confirmationScript); Assert.Contains("dialog.showModal()", confirmationScript); Assert.Contains( @@ -5577,6 +5898,7 @@ public async Task Embedded_first_party_assets_are_served_and_unknown_assets_are_ confirmationScript); Assert.Contains("cancel.focus()", confirmationScript); Assert.Contains("dialog.close(\"cancel\")", confirmationScript); + Assert.Contains("wouldReplaceTrigger", confirmationScript); Assert.DoesNotContain("confirm(", applicationScript); Assert.DoesNotContain("confirm(", confirmationScript); Assert.Contains("navigator.clipboard?.writeText", applicationScript); @@ -6737,6 +7059,17 @@ private static HttpRequestMessage AuthenticatedGet(RunningServer host, string ur return request; } + private static async Task ItemHtml( + HttpClient client, + RunningServer host, + string id) + { + using var request = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Item&id={Uri.EscapeDataString(id)}"); + return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + } + private static async Task PostForm( HttpClient client, RunningServer host, diff --git a/tests/javascript/board-controls.test.mjs b/tests/javascript/board-controls.test.mjs index 3cffd90..0bfe082 100644 --- a/tests/javascript/board-controls.test.mjs +++ b/tests/javascript/board-controls.test.mjs @@ -26,11 +26,50 @@ function control(index) { } test("captures only a focused per-column sort control", () => { - assert.equal(captureBoardControlFocus({ activeElement: control("2") }), "2"); + assert.equal(captureBoardControlFocus({ activeElement: control("2") }), "sort:2"); assert.equal(captureBoardControlFocus({ activeElement: { matches: () => false } }), null); assert.equal(captureBoardControlFocus({ activeElement: null }), null); }); +test("captures and restores a bulk action by semantic form id", () => { + const button = { + matches: () => false, + closest: selector => selector === "[data-board-bulk-action]" + ? { id: "board-bulk-resume-column-2" } + : null + }; + const replacement = { focused: false, focus() { this.focused = true; } }; + const doc = { + activeElement: button, + getElementById: id => id === "board-bulk-resume-column-2" + ? { querySelector: () => replacement } + : null, + querySelector: () => null, + querySelectorAll: () => [] + }; + + const key = captureBoardControlFocus(doc); + assert.equal(key, "bulk:board-bulk-resume-column-2"); + assert.equal(restoreBoardControlFocus(doc, key), true); + assert.equal(replacement.focused, true); +}); + +test("bulk focus falls back to the originating column when its action disappears", () => { + const heading = { focused: false, focus() { this.focused = true; } }; + const doc = { + getElementById: () => null, + querySelector: selector => selector === '[data-board-column-index="2"] h2' + ? heading + : null, + querySelectorAll: () => [] + }; + + assert.equal( + restoreBoardControlFocus(doc, "bulk:board-bulk-resume-column-2"), + true); + assert.equal(heading.focused, true); +}); + test("direction buttons announce state and select the opposite direction", () => { const attributes = {}; const button = { diff --git a/tests/javascript/confirmation-dialog.test.mjs b/tests/javascript/confirmation-dialog.test.mjs index 58fe126..f98d832 100644 --- a/tests/javascript/confirmation-dialog.test.mjs +++ b/tests/javascript/confirmation-dialog.test.mjs @@ -24,9 +24,9 @@ class FakeElement { this.listeners.set(type, listeners); } - dispatch(type) { + dispatch(type, event = {}) { const listeners = this.listeners.get(type) ?? []; - listeners.forEach(({ listener }) => listener()); + listeners.forEach(({ listener }) => listener(event)); this.listeners.set(type, listeners.filter(({ once }) => !once)); } @@ -141,6 +141,38 @@ test("confirmation presents the requested content and restores focus", async () assert.equal("tone" in dialog.dataset, false); }); +test("accept explicitly closes with a confirmed return value", async () => { + const harness = createHarness(); + const dialog = harness.element("#confirmation-dialog"); + const result = harness.controller.requestConfirmation({ message: "Move these items?" }); + const click = cancellableEvent(); + + harness.element("#confirmation-dialog-accept").dispatch("click", click); + + assert.equal(click.defaultPrevented, true); + assert.equal(dialog.open, false); + assert.equal(dialog.returnValue, "confirm"); + assert.equal(await result, true); +}); + +test("an open confirmation protects its originating control from replacement", async () => { + const harness = createHarness(); + const dialog = harness.element("#confirmation-dialog"); + const trigger = new FakeElement(harness.document); + const containingRegion = { contains: element => element === trigger }; + const otherRegion = { contains: () => false }; + const result = harness.controller.requestConfirmation( + { message: "Move these items?" }, + trigger); + + assert.equal(harness.controller.wouldReplaceTrigger(containingRegion), true); + assert.equal(harness.controller.wouldReplaceTrigger(otherRegion), false); + + dialog.close("cancel"); + assert.equal(await result, false); + assert.equal(harness.controller.wouldReplaceTrigger(containingRegion), false); +}); + test("an open confirmation rejects a second request", async () => { const harness = createHarness(); const dialog = harness.element("#confirmation-dialog");