diff --git a/console/README.md b/console/README.md index 64341ac1..6b19bfc8 100644 --- a/console/README.md +++ b/console/README.md @@ -138,6 +138,11 @@ and Ready tag colours, Landing keeps Working's live-activity marker, and a live row given a Stop shows Stopping with a drifting stripe until it ends. Pause motion and reduced motion hold the stripe still. +Dismiss and Undo in Command details immediately show “Dismissing…” or +“Taking back dismissal…” while the request is pending. The button stays disabled +through live updates to prevent duplicate submissions. A failed request shows +the error and enables the button for another try. + Ended rows show their recorded finish date and time in UTC, followed by an explicit relative age, such as “Ended Oct 23, 2026 at 23:03:17 UTC · 52 min ago”. That age is time since finish, not run duration. Each nested Ticket run uses its diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index edaba306..bae1b670 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -85,6 +85,83 @@ test('Dismiss keeps Undo in Command details and updates another open Inbox', asy assert.equal(await page.evaluate(() => window.sameDocument), true); }); +test('Dismiss and Undo show pending feedback through live refresh and prevent duplicate submissions', async t => { + const page = await pageFor(t); + await page.goto(origin); + await page.locator('[data-group="needs you"] .peek-link').first().click(); + const form = page.locator('form[data-dismiss]'); + await form.waitFor(); + const recordPath = await form.locator('[name="command"]').inputValue(); + const original = await readFile(recordPath, 'utf8'); + try { + for (const [path, label, pending, next] of [ + ['/dismiss', 'Dismiss', 'Dismissing…', 'Undo'], + ['/dismiss/undo', 'Undo', 'Taking back dismissal…', 'Dismiss'] + ]) { + let release; + const held = new Promise(resolve => { release = resolve; }); + let requests = 0; + await page.route(`${origin}${path}`, async route => { + requests++; + await held; + await route.continue(); + }); + try { + await page.getByRole('button', { name: label, exact: true }).click(); + await page.waitForFunction(pending => document.querySelector('form[data-dismiss] [role="status"]').textContent === pending, pending, { timeout: 2000 }); + assert.equal(await form.locator('button').isDisabled(), true); + const updated = JSON.parse(original); + updated.cause = `Live refresh while ${label} is pending`; + await writeFile(recordPath, JSON.stringify(updated)); + await page.locator('#peek-content').getByText(updated.cause, { exact: true }).waitFor(); + assert.equal(await form.getByRole('status').textContent(), pending); + assert.equal(await form.locator('button').isDisabled(), true); + // A second submit event must be ignored even if a browser dispatches it directly. + await form.evaluate(form => form.requestSubmit()); + release(); + await page.getByRole('button', { name: next, exact: true }).waitFor({ timeout: 3000 }); + await page.waitForFunction(() => !document.querySelector('form[data-dismiss] button').disabled); + assert.equal(requests, 1); + } finally { + release(); + await page.unrouteAll({ behavior: 'wait' }); + } + } + } finally { await writeFile(recordPath, original); } +}); + +test('A failed Dismiss shows the error through live refresh and allows retry', async t => { + const page = await pageFor(t); + await page.goto(origin); + await page.locator('[data-group="needs you"] .peek-link').first().click(); + const form = page.locator('form[data-dismiss]'); + await form.waitFor(); + const recordPath = await form.locator('[name="command"]').inputValue(); + const original = await readFile(recordPath, 'utf8'); + await page.route(`${origin}/dismiss`, route => route.fulfill({ status: 409, body: 'Please try again' })); + try { + await page.getByRole('button', { name: 'Dismiss', exact: true }).click(); + const error = 'Could not save the dismissal: Please try again'; + await form.getByRole('status').getByText(error, { exact: true }).waitFor(); + assert.equal(await form.locator('button').isEnabled(), true); + const updated = JSON.parse(original); + updated.cause = 'Live refresh after failed dismissal'; + await writeFile(recordPath, JSON.stringify(updated)); + await page.locator('#peek-content').getByText(updated.cause, { exact: true }).waitFor(); + assert.equal(await form.getByRole('status').textContent(), error); + await page.unrouteAll({ behavior: 'wait' }); + await page.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByRole('button', { name: 'Undo', exact: true }).waitFor(); + await page.waitForFunction(() => !document.querySelector('form[data-dismiss] button').disabled); + assert.equal(await form.getByRole('status').textContent(), ''); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await page.getByRole('button', { name: 'Dismiss', exact: true }).waitFor(); + } finally { + await page.unrouteAll({ behavior: 'wait' }); + await writeFile(recordPath, original); + } +}); + test("Shift+T switches all built-in Themes and saves hidden sections across browsers", async t => { const page = await pageFor(t); const remote = []; diff --git a/docs/guide.md b/docs/guide.md index cf235c8b..f788f1d0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -829,8 +829,11 @@ back, naming the Unix user and the CLI. `dismiss --json` reports what it did. In the Console, any signed-in account can click **Dismiss** in a waiting row's Command details, beside Stop when available. One click dismisses, with no -confirmation page. A dismissed row offers **Undo** there. Each button shows its -CLI form beside it. Both posts require a session and a valid CSRF token, open +confirmation page. A dismissed row offers **Undo** there. While either request +is pending, Command details shows “Dismissing…” or “Taking back dismissal…” +and disables the button, including through live updates. A failed request shows +the error and lets you retry. Each button shows its CLI form beside it. Both +posts require a session and a valid CSRF token, open pages update together, and the Activity log names the account's email and the Console. diff --git a/site/architecture/index.html b/site/architecture/index.html index b8a0de6e..9d6afaf6 100644 --- a/site/architecture/index.html +++ b/site/architecture/index.html @@ -197,7 +197,7 @@
The Console reads this machine's Commands and opens on its Inbox. Filesystem changes wake its Inbox and log readers; process exits and elapsed-time boundaries have their own wakeups. A slow recovery check catches missed notifications and expired sessions. A signed-in account can Stop or Requeue a live Command. It does nothing the CLI can't do and starts no work (ADR-0016).
The Console reads ready rows' pull requests and Needs you rows' issues and pull requests in a batched GraphQL query in the background once a minute, with blocking gh calls off its async thread. It keeps one answer per machine beside the logs. A changed answer or warning wakes open Inbox pages through the file watcher; unchanged reads update freshness without rendering or waking pages. thirdshift status uses that answer and reads GitHub itself only when it is missing or five minutes old. Failed reads retain the last facts and failure; after five minutes without a good read, both readers warn with when GitHub last answered and why the latest read failed. Both share a quota gate: fewer than 500 GraphQL points or REST requests holds off all their reads until reset, with a warning, preserving the rest for the factory. These limits and timings are not User config settings. An ended Needs you row moves to Done, Resolved or History when its issue closes or its pull request merges; a Triage row also moves when needs-triage is removed. Architect and Security runs use their dispatched Spec run's or Ticket run's issue and pull request. Live Silent and Slow rows stay until their Commands end. Ready rows leave on a merged or closed pull request. Resolved counts from the settlement time, and only rows GitHub cannot speak for age out while still waiting. Reopening brings back rows still in Done or Resolved. Rows in History retain the last facts without further reads, including when another row shares their issue or pull request. Dismissals hold across GitHub changes, and Undo uses the latest kept facts. These reads never write to GitHub (ADR-0023).
Command details offers any signed-in account Dismiss for a row in Needs you or Ready for review, and Undo for a dismissed row, each beside its CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. Both use the Console's session and CSRF checks. The dismissal lives beside the Command record and changes only the row's group; filesystem notifications update open pages, and the Activity log names the account and the Console. A live Command keeps running, and nothing is written to GitHub.
Command details offers any signed-in account Dismiss for a row in Needs you or Ready for review, and Undo for a dismissed row, each beside its CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. Both use the Console's session and CSRF checks. The browser keeps pending feedback and disables duplicate submissions through live updates; failed requests show their error and allow a retry. The dismissal lives beside the Command record and changes only the row's group; filesystem notifications update open pages, and the Activity log names the account and the Console. A live Command keeps running, and nothing is written to GitHub.
A dismissal accumulates covered kinds for the whole row, across child Ticket runs, and status --json exposes them as dismissal.kinds. An uncovered kind brings the row back, including a Finding behind a dismissed Security failure only while GitHub still shows its findings waiting. Each read adds one paginated REST list per repository with a Security run whose findings were waiting, respecting the quota reserve and using the Security run’s private-storage selection. The kept answer holds only finding URLs and waiting flags, with the first observed triage time; it retains no finding title, severity or write-up. A Finding leaves saying “findings triaged” once its records no longer wait: a draft advisory without severity or an open security-finding issue labelled needs-triage waits, and a record closed by a merged fix does not.
status --json exposes each row's kept issue, pull request and finding facts with their observation times in github. History freezes those times alongside the facts. Unchanged reads update observation times without waking pages. A dismissed row's Resolved clock stays anchored to its dismissal even if GitHub settles it later.
Before each agent session, optional host resource limits delay its start while available memory is too low or one-minute CPU load is too high. thirdshift resources and the Console's Resources page show the current snapshot and configured limits. Existing sessions continue.
dismiss --json reports what it did.
In the Console, any signed-in account can click Dismiss in a waiting row's Command details, beside Stop when available. One click dismisses, with no -confirmation page. A dismissed row offers Undo there. Each button shows its -CLI form beside it. Both posts require a session and a valid CSRF token, open +confirmation page. A dismissed row offers Undo there. While either request +is pending, Command details shows “Dismissing…” or “Taking back dismissal…” +and disables the button, including through live updates. A failed request shows +the error and lets you retry. Each button shows its CLI form beside it. Both +posts require a session and a valid CSRF token, open pages update together, and the Activity log names the account's email and the Console.
diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index f0124e72..c4c955a4 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -168,7 +168,7 @@thirdshift stop <command>
thirdshift stop <command> --requeue
Stop waits for cleanup and leaves the issue for the Day shift, labelled needs-triage. With --requeue, it releases the Claim and puts ready-for-agent back so Pickup can continue the work. Stopping a Command.
A row in Needs you or Ready for review that asks nothing more can be Dismissed instead, live ones included. Any signed-in Console account can click Dismiss in Command details, beside Stop when available, with no confirmation page. A dismissed row offers Undo there. The CLI forms appear beside the buttons: thirdshift dismiss <command> and thirdshift dismiss <command> --undo. Every open page updates, and the Activity log names the account's email and the Console. Nothing else changes: the Command keeps running and nothing is written to GitHub. Dismissing a row.
A row in Needs you or Ready for review that asks nothing more can be Dismissed instead, live ones included. Any signed-in Console account can click Dismiss in Command details, beside Stop when available, with no confirmation page. A dismissed row offers Undo there. Both buttons immediately show pending feedback and stay disabled while saving, including through live updates; a failed request shows its error and lets you retry. The CLI forms appear beside the buttons: thirdshift dismiss <command> and thirdshift dismiss <command> --undo. Every open page updates, and the Activity log names the account's email and the Console. Nothing else changes: the Command keeps running and nothing is written to GitHub. Dismissing a row.
Slow becoming Silent, Silent becoming Slow, or a live Command dying, failing or ending ready brings a dismissed row back. A dismissed Security failure returns as a Finding only while GitHub still shows its findings waiting. One paginated REST list per repository with such a Security run reads the private records, respecting the quota reserve. A Finding leaves saying “findings triaged” once all its records are triaged: draft advisories without severity and open security-finding issues labelled needs-triage still wait; records closed by a merged fix do not. The kept answer holds waiting flags, never titles, severities or write-ups. Dismissing again adds the new kind; another Ticket going Silent in the same Spec run does not repeat a kind already dismissed.
The Command log records the whole command; a Session trace records each agent session, and the Activity log records starts and outcomes per repository. Export a Command's whole Trace for another tool with thirdshift logs <command> --export <dir>. Optional email notifications report how the Ticket run ended. Inspecting logs and exporting Traces.