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
5 changes: 5 additions & 0 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions console/tests/inbox.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
7 changes: 5 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion site/architecture/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ <h1>Architecture</h1>
</figure>
<p>The <a href="/docs/#console-installation">Console</a> reads this machine's Commands and opens on its <strong>Inbox</strong>. 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 <strong>Stop</strong> or Requeue a live Command. It does nothing the CLI can't do and starts no work (<a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0016-the-console-is-a-long-running-reader.md">ADR-0016</a>).</p>
<p>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 <code>gh</code> 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. <code>thirdshift status</code> 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 <code>needs-triage</code> 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 (<a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0023-the-inbox-reads-github-to-learn-the-day-shift-acted.md">ADR-0023</a>).</p>
<p>Command details offers any signed-in account <a href="/docs/#dismissing-a-row">Dismiss</a> for a row in Needs you or Ready for review, and Undo for a dismissed row, each beside its CLI form: <code>thirdshift dismiss &lt;command&gt;</code> or <code>thirdshift dismiss &lt;command&gt; --undo</code>. 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.</p>
<p>Command details offers any signed-in account <a href="/docs/#dismissing-a-row">Dismiss</a> for a row in Needs you or Ready for review, and Undo for a dismissed row, each beside its CLI form: <code>thirdshift dismiss &lt;command&gt;</code> or <code>thirdshift dismiss &lt;command&gt; --undo</code>. 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.</p>
<p>A dismissal accumulates covered kinds for the whole row, across child Ticket runs, and <code>status --json</code> exposes them as <code>dismissal.kinds</code>. 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 <code>security-finding</code> issue labelled <code>needs-triage</code> waits, and a record closed by a merged fix does not.</p>
<p><code>status --json</code> exposes each row's kept issue, pull request and finding facts with their observation times in <code>github</code>. 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.</p>
<p>Before each agent session, optional <a href="/docs/#host-resources">host resource limits</a> delay its start while available memory is too low or one-minute CPU load is too high. <code>thirdshift resources</code> and the Console's Resources page show the current snapshot and configured limits. Existing sessions continue.</p>
Expand Down
7 changes: 5 additions & 2 deletions site/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -731,8 +731,11 @@
back, naming the Unix user and the CLI. <code>dismiss --json</code> reports what it did.</p>
<p>In the Console, any signed-in account can click <strong>Dismiss</strong> in a waiting row&#39;s
Command details, beside Stop when available. One click dismisses, with no
confirmation page. A dismissed row offers <strong>Undo</strong> 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 <strong>Undo</strong> 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&#39;s email and the
Console.</p>
</div></details>
Expand Down
2 changes: 1 addition & 1 deletion site/how-it-works/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ <h1>How thirdshift works</h1>
<pre class="guide-command"><code>thirdshift stop &lt;command&gt;
thirdshift stop &lt;command&gt; --requeue</code></pre>
<p>Stop waits for cleanup and leaves the issue for the Day shift, labelled <code>needs-triage</code>. With <code>--requeue</code>, it releases the <strong>Claim</strong> and puts <code>ready-for-agent</code> back so Pickup can continue the work. <a href="/docs/#stopping-a-command">Stopping a Command</a>.</p>
<p>A row in Needs you or Ready for review that asks nothing more can be <strong>Dismissed</strong> 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: <code>thirdshift dismiss &lt;command&gt;</code> and <code>thirdshift dismiss &lt;command&gt; --undo</code>. 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. <a href="/docs/#dismissing-a-row">Dismissing a row</a>.</p>
<p>A row in Needs you or Ready for review that asks nothing more can be <strong>Dismissed</strong> 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: <code>thirdshift dismiss &lt;command&gt;</code> and <code>thirdshift dismiss &lt;command&gt; --undo</code>. 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. <a href="/docs/#dismissing-a-row">Dismissing a row</a>.</p>
<p>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 <code>security-finding</code> issues labelled <code>needs-triage</code> 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.</p>
<p>The <strong>Command log</strong> records the whole command; a <strong>Session trace</strong> records each agent session, and the <strong>Activity log</strong> records starts and outcomes per repository. Export a Command's whole <strong>Trace</strong> for another tool with <code>thirdshift logs &lt;command&gt; --export &lt;dir&gt;</code>. Optional email notifications report how the Ticket run ended. <a href="/docs/#logs">Inspecting logs and exporting Traces</a>.</p>
</div>
Expand Down
35 changes: 31 additions & 4 deletions src/console/assets/console.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ const jumps = document.querySelector('#jumps');
let selected = null;
let peekCommand = null;
let pendingPeek = null;
// Details can be replaced or reopened while a dismissal request is in flight.
const dismissals = new Map();

function showDismissal() {
const form = content.querySelector('form[data-dismiss]');
if (!form) return;
const state = dismissals.get(form.elements.command.value);
form.querySelector('button').disabled = state?.pending ?? false;
form.querySelector('[role="status"]').textContent = state && (state.pending || state.action === form.action)
? state.message : '';
}

function initializeSpanTree() { return initSpanTree({
openDetails() {
Expand Down Expand Up @@ -248,6 +259,7 @@ async function loadPeek(url) {
// Only the server's escaped show template is inserted, never a log.
content.replaceChildren(document.createRange().createContextualFragment(html));
restoreCopy();
showDismissal();
if (content.querySelector('[data-live="true"]')) startUpdates();
else stopPeekUpdates();
} catch (error) {
Expand Down Expand Up @@ -309,18 +321,33 @@ content.addEventListener('submit', async event => {
const form = event.target.closest('form[data-dismiss]');
if (!form) return;
event.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
const command = form.elements.command.value;
if (dismissals.get(command)?.pending) return;
const undo = new URL(form.action).pathname === '/dismiss/undo';
const state = {
action: form.action, pending: true,
message: undo ? 'Taking back dismissal…' : 'Dismissing…'
};
dismissals.set(command, state);
showDismissal();
let saved = false;
try {
const response = await fetch(form.action, {
method: 'POST', body: new URLSearchParams(new FormData(form))
});
if (!response.ok) throw new Error(await response.text());
saved = true;
pendingPeek?.abort();
pendingPeek = null;
await refresh();
dismissals.delete(command);
} catch (error) {
form.querySelector('[role="status"]').textContent = `Could not save the dismissal: ${error.message}`;
state.message = saved
? `Dismissal saved. Could not refresh details: ${error.message}`
: `Could not save the dismissal: ${error.message}`;
} finally {
button.disabled = false;
state.pending = false;
showDismissal();
}
});
document.querySelector('[data-close-peek]').addEventListener('click', closePeek);
Expand Down
Loading