mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 04:42:24 +00:00
TASK-console-attention-actions: add guarded decision shortcuts
This commit is contained in:
1 parent
9c806c4b09
commit
65367dccbb
8 files changed
+173
-2
No files matched your search
@@ -0,0 +1,37 @@
|
||||
# Test integrity note — TASK-console-attention-actions
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: [new_tests]
|
||||
scenarios:
|
||||
- id: SCN-console-attention-owner-shortcut
|
||||
given: "A pending approval names the project owner and has an available decision form."
|
||||
when: "The owner chooses an Approve or Reject shortcut from its attention card."
|
||||
then: "Only the matching decision field is selected and the existing preview path starts; no decision is submitted directly."
|
||||
- id: SCN-console-attention-shortcut-drafts
|
||||
given: "The form is unavailable, has an active preview, or contains the opposite decision draft."
|
||||
when: "An attention shortcut is selected."
|
||||
then: "The action is declined with an explicit status and existing draft or preview values remain unchanged."
|
||||
- id: SCN-console-attention-ownership
|
||||
given: "An approval is pending for the owner or another named approver."
|
||||
when: "Attention actions are projected."
|
||||
then: "Only the owner-addressed item carries an owner decision shortcut identifier."
|
||||
observable_outcomes:
|
||||
- "Owner attention cards expose Approve and Reject controls only for pending requests addressed to owner."
|
||||
- "A shortcut enters the existing revision/evidence preview and confirmation flow."
|
||||
- "Missing forms, active previews, and conflicting drafts remain visible and unchanged."
|
||||
- "Long evidence is available through a compact expandable disclosure."
|
||||
determinism_controls:
|
||||
- "Projection and shortcut scenarios use fixed in-memory fixtures without clock, network, or randomness."
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "direct decision submission without preview and confirmation"
|
||||
- "peer approval authority exposed as an owner control"
|
||||
- "overwriting a pending decision draft or preview"
|
||||
- "long evidence silently omitted from compact cards"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
Existing approval-console scenarios continue to cover stale revisions, named-approver enforcement, preview confirmation, and failed decisions. The attention tests add coverage at the UI projection and shortcut boundary; actual desktop/mobile visual acceptance remains separate and incomplete until a real browser capture is available.
|
||||
@@ -0,0 +1,25 @@
|
||||
(function (global) {
|
||||
function prepareOwnerDecisionShortcut(form, decision, beginPreview) {
|
||||
if (!['approved', 'rejected'].includes(decision)) {
|
||||
return { started: false, reason: 'Choose approve or reject from an owner-only shortcut.' };
|
||||
}
|
||||
const choice = form?.elements?.namedItem('decision');
|
||||
if (!form || !choice) {
|
||||
return { started: false, reason: 'This owner decision form is no longer available. Refresh the current evidence before acting.' };
|
||||
}
|
||||
if (form.dataset.previewToken || choice.disabled) {
|
||||
return { started: false, reason: 'An existing decision preview is preserved. Review or edit it in Flow watch before using another shortcut.' };
|
||||
}
|
||||
if (choice.value && choice.value !== decision) {
|
||||
return { started: false, reason: 'Your existing decision draft is preserved. Review or change it in Flow watch before choosing another shortcut.' };
|
||||
}
|
||||
if (typeof beginPreview !== 'function') {
|
||||
return { started: false, reason: 'The guarded decision preview is unavailable. Refresh before acting.' };
|
||||
}
|
||||
choice.value = decision;
|
||||
beginPreview?.(form);
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
global.TorchAttentionActions = Object.freeze({ prepareOwnerDecisionShortcut });
|
||||
})(globalThis);
|
||||
@@ -27,6 +27,7 @@ function attentionGroups(snapshot) {
|
||||
waitSince: approval.createdAt ?? null,
|
||||
href: `#approval-${encodeURIComponent(approval.id)}`,
|
||||
action: isOwner ? 'Review request and evidence' : 'Open approval details',
|
||||
decisionApprovalId: isOwner ? approval.id : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<script src="/task-activity.js" defer></script>
|
||||
<script src="/operation-outcomes.js" defer></script>
|
||||
<script src="/attention-projection.js" defer></script>
|
||||
<script src="/attention-actions.js" defer></script>
|
||||
<script src="/live-refresh.js" defer></script>
|
||||
<script src="/demo.js" defer></script>
|
||||
<script src="/console.js" defer></script>
|
||||
|
||||
+35
-1
@@ -743,8 +743,16 @@ function renderAttention(groups) {
|
||||
${items.length ? items.map((item) => `<article class="attention-item tone-${escapeHtml(item.tone)}">
|
||||
<span class="attention-mark" aria-hidden="true"></span><div class="attention-copy">
|
||||
<strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail)}</p>
|
||||
<small>Severity: ${escapeHtml(({ urgent: 'Urgent', review: 'Review', decision: 'Decision required', info: 'Progress' })[item.tone] ?? 'Unknown')} · Owner: ${escapeHtml(item.owner)}${item.waitSince ? ` · Waiting ${escapeHtml(ageLabel(item.waitSince))}` : ''} · ${escapeHtml(item.evidence)}</small>
|
||||
<small>Severity: ${escapeHtml(({ urgent: 'Urgent', review: 'Review', decision: 'Decision required', info: 'Progress' })[item.tone] ?? 'Unknown')} · Owner: ${escapeHtml(item.owner)}${item.waitSince ? ` · Waiting ${escapeHtml(ageLabel(item.waitSince))}` : ''}</small>
|
||||
${item.evidence.length > 120
|
||||
? `<details class="attention-evidence"><summary>Evidence and references</summary><p>${escapeHtml(item.evidence)}</p></details>`
|
||||
: `<small class="attention-evidence-short">Evidence: ${escapeHtml(item.evidence)}</small>`}
|
||||
<a href="${escapeHtml(item.href)}">${escapeHtml(item.action)}</a>
|
||||
${item.decisionApprovalId ? `<div class="attention-quick-actions" role="group" aria-label="Owner decision shortcuts for ${escapeHtml(item.title)}">
|
||||
<button type="button" data-attention-decision="approved" data-attention-approval="${escapeHtml(item.decisionApprovalId)}">Approve</button>
|
||||
<button type="button" class="quiet-action" data-attention-decision="rejected" data-attention-approval="${escapeHtml(item.decisionApprovalId)}">Reject</button>
|
||||
<p class="attention-action-status" role="status" aria-live="polite" hidden></p>
|
||||
</div>` : ''}
|
||||
${item.requestOwner ? `<a href="#owner-request-form" data-attention-recipient="${escapeHtml(item.requestOwner)}">Request review from ${escapeHtml(item.owner)}</a>` : ''}
|
||||
</div></article>`).join('') : `<p class="attention-empty">${escapeHtml(description)}</p>`}
|
||||
</section>`;
|
||||
@@ -1829,6 +1837,32 @@ $('#change-list')?.addEventListener('click', async (event) => {
|
||||
});
|
||||
|
||||
$('#attention-list')?.addEventListener('click', (event) => {
|
||||
const decisionButton = event.target.closest('[data-attention-decision]');
|
||||
if (decisionButton) {
|
||||
const card = decisionButton.closest('.attention-item');
|
||||
const status = card?.querySelector('.attention-action-status');
|
||||
const approvalId = decisionButton.dataset.attentionApproval;
|
||||
const decision = decisionButton.dataset.attentionDecision;
|
||||
const form = [...document.querySelectorAll('#approval-request-list .approval-decision-form')]
|
||||
.find((candidate) => candidate.dataset.approvalId === approvalId);
|
||||
const previewButton = form?.querySelector('[data-approval-preview]');
|
||||
const shortcut = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(form, decision, previewButton ? (approvalForm) => {
|
||||
approvalForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
previewButton.click();
|
||||
} : null);
|
||||
if (!shortcut.started) {
|
||||
if (status) {
|
||||
status.textContent = shortcut.reason;
|
||||
status.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = `Opening the guarded ${decision} preview for this owner-addressed request. Confirm only after reviewing its current evidence.`;
|
||||
status.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const link = event.target.closest('a[href^="#"]');
|
||||
const target = link && document.getElementById(link.getAttribute('href').slice(1));
|
||||
const recipient = link?.dataset.attentionRecipient;
|
||||
|
||||
@@ -190,6 +190,13 @@ footer div { display: flex; gap: 1.5rem; }
|
||||
.attention-item p { margin: .18rem 0 .35rem; color: var(--muted); font-size: .78rem; line-height: 1.4; }
|
||||
.attention-item small { display: block; color: var(--muted); font-size: .69rem; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.attention-item a { display: inline-block; margin: .35rem .8rem 0 0; color: var(--route); font-size: .76rem; font-weight: 650; }
|
||||
.attention-evidence { margin-top: .28rem; color: var(--muted); font-size: .69rem; }
|
||||
.attention-evidence summary { width: fit-content; cursor: pointer; color: var(--route); font-weight: 650; }
|
||||
.attention-evidence p { margin: .25rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.attention-quick-actions { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-top: .4rem; }
|
||||
.attention-quick-actions button { min-height: 2rem; padding: .3rem .55rem; }
|
||||
.attention-action-status { flex-basis: 100%; margin: .1rem 0 0; color: var(--muted); font-size: .7rem; line-height: 1.4; }
|
||||
.attention-action-status[hidden] { display: none; }
|
||||
.board-section, .console-section { padding-block: 1.65rem; border-bottom: 1px solid var(--line); scroll-margin-top: 1rem; }
|
||||
.section-heading { margin-bottom: 1rem; }
|
||||
.section-heading h2 { margin: 0; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import '../../site/attention-projection.js';
|
||||
import '../../site/attention-actions.js';
|
||||
import '../../site/live-refresh.js';
|
||||
|
||||
const project = (snapshot) => globalThis.TorchAttentionProjection.groups(snapshot);
|
||||
|
||||
@@ -20,13 +22,74 @@ test('SCN-console-attention-ownership: only owner-addressed pending decisions en
|
||||
|
||||
assert.deepEqual(groups.owner.map((item) => item.title), ['Review executor boundary']);
|
||||
assert.equal(groups.owner[0].href, '#approval-owner-review');
|
||||
assert.equal(groups.owner[0].decisionApprovalId, 'owner-review');
|
||||
assert.match(groups.owner[0].detail, /bounded scope/);
|
||||
assert.equal(groups.owner[0].waitSince, '2026-10-01T06:00:00Z');
|
||||
assert.equal(groups.fleet[0].owner, 'Independent QA');
|
||||
assert.equal(groups.fleet[0].decisionApprovalId, null);
|
||||
assert.equal(groups.fleet[0].action, 'Open approval details');
|
||||
assert.doesNotMatch(JSON.stringify(groups.fleet), /Approve|Reject/);
|
||||
});
|
||||
|
||||
test('SCN-console-attention-owner-shortcut: a shortcut selects only an owner decision and retains the preview-confirm boundary', () => {
|
||||
const choice = { value: '', disabled: false };
|
||||
const form = { dataset: {}, elements: { namedItem: () => choice } };
|
||||
let previewCalls = 0;
|
||||
const result = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(form, 'approved', () => { previewCalls += 1; });
|
||||
|
||||
assert.equal(result.started, true);
|
||||
assert.equal(choice.value, 'approved');
|
||||
assert.equal(previewCalls, 1, 'the caller can only start the established preview handler');
|
||||
|
||||
const nonOwnerForm = { dataset: {}, elements: { namedItem: () => ({ value: '', disabled: false }) } };
|
||||
const noOwnerShortcut = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(nonOwnerForm, 'published', () => { previewCalls += 1; });
|
||||
assert.equal(noOwnerShortcut.started, false);
|
||||
assert.equal(nonOwnerForm.elements.namedItem().value, '');
|
||||
});
|
||||
|
||||
test('SCN-console-attention-shortcut-drafts: missing forms, active previews, and opposite drafts remain unchanged', () => {
|
||||
const missing = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(null, 'rejected');
|
||||
assert.equal(missing.started, false);
|
||||
assert.match(missing.reason, /no longer available/);
|
||||
|
||||
const previewForm = { dataset: { previewToken: 'current-preview' },
|
||||
elements: { namedItem: () => ({ value: 'approved', disabled: true }) } };
|
||||
const preview = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(previewForm, 'rejected');
|
||||
assert.equal(preview.started, false);
|
||||
assert.equal(previewForm.elements.namedItem().value, 'approved');
|
||||
|
||||
const draftChoice = { value: 'approved', disabled: false };
|
||||
const draftForm = { dataset: {}, elements: { namedItem: () => draftChoice } };
|
||||
const draft = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(draftForm, 'rejected');
|
||||
assert.equal(draft.started, false);
|
||||
assert.equal(draftChoice.value, 'approved');
|
||||
assert.match(draft.reason, /draft is preserved/);
|
||||
});
|
||||
|
||||
test('SCN-console-attention-refresh-draft: an edited owner decision remains protected while newer evidence renders', () => {
|
||||
const choice = { value: '', checked: false, tagName: 'SELECT', selectedOptions: [] };
|
||||
const note = { value: '', checked: false, tagName: 'TEXTAREA' };
|
||||
const fields = [choice, note];
|
||||
const form = {
|
||||
contains: () => false,
|
||||
matches: () => false,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => fields,
|
||||
};
|
||||
const document = { activeElement: null, querySelectorAll: () => fields };
|
||||
const protection = globalThis.TorchLiveRefresh.createProtection(document);
|
||||
protection.remember();
|
||||
choice.value = 'rejected';
|
||||
note.value = 'Need the exact impact first.';
|
||||
|
||||
assert.equal(protection.protects(form), true);
|
||||
const dirty = protection.dirty();
|
||||
protection.remember({ preserve: dirty });
|
||||
assert.equal(protection.protects(form), true);
|
||||
assert.equal(choice.value, 'rejected');
|
||||
assert.equal(note.value, 'Need the exact impact first.');
|
||||
});
|
||||
|
||||
test('SCN-console-attention-dedup: doctor and snapshot message counts become one honest queue item', () => {
|
||||
const groups = project({
|
||||
messages: { unacknowledged: 4 },
|
||||
|
||||
@@ -30,6 +30,7 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
|
||||
const html = readFileSync(new URL('../../site/index.html', import.meta.url), 'utf8');
|
||||
const demoHtml = readFileSync(new URL('../../site/demo.html', import.meta.url), 'utf8');
|
||||
const consoleHtml = readFileSync(new URL('../../site/console.html', import.meta.url), 'utf8');
|
||||
const consoleScript = readFileSync(new URL('../../site/console.js', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../../site/styles.css', import.meta.url), 'utf8');
|
||||
const torchMark = readFileSync(new URL('../../site/torch-mark.svg', import.meta.url), 'utf8');
|
||||
assert.match(html, /<title>TORCH — an AI fleet shaped by your project<\/title>/);
|
||||
@@ -70,6 +71,9 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
|
||||
assert.match(consoleHtml, /console-rail/);
|
||||
assert.match(consoleHtml, /id="backlog-board"/);
|
||||
assert.match(consoleHtml, /id="attention-list"/);
|
||||
assert.match(consoleHtml, /src="\/attention-actions\.js" defer/);
|
||||
assert.match(consoleScript, /data-attention-decision/);
|
||||
assert.match(consoleScript, /data-approval-preview/);
|
||||
assert.match(consoleHtml, /id="artifact-list"/);
|
||||
assert.match(consoleHtml, /id="organization-map"/);
|
||||
assert.match(consoleHtml, /id="organization-lifecycle"/);
|
||||
@@ -93,7 +97,6 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
|
||||
assert.match(consoleHtml, /Create a task proposal/);
|
||||
assert.match(consoleHtml, /Session Manager still owns triage and assignment/);
|
||||
assert.match(consoleHtml, /gates remain separate/);
|
||||
const consoleScript = readFileSync(new URL('../../site/console.js', import.meta.url), 'utf8');
|
||||
assert.match(consoleScript, /artifact-feedback/);
|
||||
assert.match(consoleScript, /feedback\/preview/);
|
||||
assert.match(consoleScript, /data-feedback-confirm/);
|
||||
|
||||
Reference in new issue
Block a user