feat(mcp): approval-queue MCP Apps widget for staged operations (#1278)

* feat(mcp): approval-queue MCP Apps widget for staged operations

gnubok_list_pending_operations(render_ui=true) now renders an interactive
approval queue (claude.ai / Claude Desktop) where the user approves or
rejects each staged operation with a click. High-risk operations arm the
approve button and the second click sends confirmed=true, so the BFL
5 kap 5 acknowledgment is a first-party human action instead of the
agent asserting confirmed=true on the user's behalf (the audit weakness
flagged in dev_docs/erpclaw_analysis.md).

- New widget ui://pending-operations/app.html following the established
  self-contained postMessage/JSON-RPC pattern (no fetch, theme-aware,
  Swedish labels, expandable preview_data per row).
- Result-level _meta.ui hint gated on render_ui=true, mirroring the VAT
  report wiring; the tool stays data-only by default.
- Widget tool references project per namespace (accounted_* clients see
  accounted_ names inside the HTML).
- tools/list payload ceiling 58K -> 58.5K per the in-test convention:
  prose trimmed to the floor first, remainder is wire contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): time out the widget RPC bridge so a silent host cannot strand a row

Review follow-up: sendRequest never settled if the host dropped a
response, leaving op._working=true forever with the approve/reject
buttons gone. A 30s timeout rejects the promise; the existing catch
paths restore the row with an error message so the user can retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 18:02:17 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent dc5aea4a35
commit 4501f118c2
6 changed files with 595 additions and 3 deletions
+1
View File
@@ -661,3 +661,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are *.dart/*.swift/*Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system.
[2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task.
[2026-07-29] Approval-queue MCP App widget (render_ui on list_pending_operations): high-risk confirmed=true now comes from a human click in-widget instead of agent-asserted; payload ceiling 58K->58.5K per the in-test trim-first convention.
@@ -145,9 +145,16 @@ describe('tools/list payload size guard', () => {
// trimmed to the floor first (agi_status, lock_period, list_employees
// gave back ~100 tokens); the ~90-token remainder is the contract
// agents read the filing state through.
// * 58K → 58.5K with the approval-queue widget: render_ui on
// gnubok_list_pending_operations opens the MCP Apps queue where
// approve/reject (and the high-risk BFL acknowledgment) are first-party
// human clicks instead of agent-asserted confirmed=true. The property +
// hint prose was trimmed to the floor first (~30 tokens recovered);
// headroom before the change was ~14 tokens, so even the trimmed wire
// contract crossed.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(58_000)
expect(approxTokens).toBeLessThan(58_500)
})
})
@@ -0,0 +1,195 @@
/**
* Tests for the pending-operations approval-queue widget: registration,
* resource serving, tool wiring, and namespace projection. Does NOT re-test
* approve/reject semantics (covered by pending-operations-tools tests);
* only the widget plumbing.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
import { uiWidgets, findUiWidget } from '../widgets'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['pending_operations:read'],
}),
// Fully-chainable, awaitable proxy resolving to empty data: satisfies
// both loadAtomsAsSkills and the pending_operations list query without
// hand-enumerating each chain.
createServiceClientNoCookies: vi.fn(() => {
const makeChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve({ data: [], error: null, count: 0 })
}
return () => makeChain()
},
},
)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
})
}
return () => membershipChain
},
}
)
return {
from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
}
}),
}
})
import { handleMcpRequest } from '../server'
function mcpRequest(method: string, params?: Record<string, unknown>, namespace?: 'accounted'): Request {
const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp')
if (namespace) url.searchParams.set('tool_namespace', namespace)
return new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
}
async function parseResult(response: Response) {
const json = await response.json()
return json.result
}
describe('Pending operations widget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('widget registration', () => {
it('registers the pending-operations widget in uiWidgets', () => {
const widget = findUiWidget('ui://pending-operations/app.html')
expect(widget).toBeDefined()
expect(widget?.name).toBe('Pending Operations')
expect(widget?.html).toContain('<!DOCTYPE html>')
expect(widget?.html).toContain('Att godkänna')
})
it('uiWidgets contains all three widgets', () => {
const uris = uiWidgets.map((w) => w.uri)
expect(uris).toContain('ui://receipt-matcher/app.html')
expect(uris).toContain('ui://vat-review/app.html')
expect(uris).toContain('ui://pending-operations/app.html')
})
it('times out stranded RPCs so a silent host cannot freeze a row', () => {
const widget = findUiWidget('ui://pending-operations/app.html')!
expect(widget.html).toContain('RPC_TIMEOUT_MS')
expect(widget.html).toContain('clearTimeout(timer)')
})
it('the widget calls the approve and reject tools and arms confirmed=true for high risk', () => {
const widget = findUiWidget('ui://pending-operations/app.html')!
expect(widget.html).toContain('gnubok_approve_pending_operation')
expect(widget.html).toContain('gnubok_reject_pending_operation')
// High-risk approvals send confirmed=true only from the armed second
// click: the human acknowledgment, never a default.
expect(widget.html).toContain('args.confirmed = true')
expect(widget.html).toContain("risk_level === 'high'")
})
})
describe('gnubok_list_pending_operations wiring', () => {
it('declares render_ui and points at the pending-operations widget', () => {
const tool = tools.find((t) => t.name === 'gnubok_list_pending_operations')!
expect((tool as { uiResourceUri?: string }).uiResourceUri).toBe(
'ui://pending-operations/app.html'
)
const props = (tool.inputSchema as { properties: Record<string, unknown> }).properties
expect(props.render_ui).toMatchObject({ type: 'boolean' })
expect(tool.annotations.readOnlyHint).toBe(true)
})
it('emits result-level _meta only when render_ui=true', async () => {
const withUi = await (
await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_list_pending_operations',
arguments: { render_ui: true },
}),
)
).json()
expect(withUi.result.isError).toBeUndefined()
expect(withUi.result._meta).toEqual({
ui: { resourceUri: 'ui://pending-operations/app.html' },
})
const withoutUi = await (
await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_list_pending_operations',
arguments: {},
}),
)
).json()
expect(withoutUi.result.isError).toBeUndefined()
expect(withoutUi.result._meta).toBeUndefined()
})
})
describe('protocol: resources/list + resources/read', () => {
it('lists the widget with the MCP Apps mime type', async () => {
const res = await handleMcpRequest(mcpRequest('resources/list'))
const result = await parseResult(res)
const widget = result.resources.find(
(r: { uri: string }) => r.uri === 'ui://pending-operations/app.html'
)
expect(widget).toMatchObject({
uri: 'ui://pending-operations/app.html',
name: 'Pending Operations',
mimeType: 'text/html;profile=mcp-app',
})
})
it('returns the widget HTML on resources/read', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'ui://pending-operations/app.html' })
)
const result = await parseResult(res)
expect(result.contents).toHaveLength(1)
expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app')
expect(result.contents[0].text).toContain('Att godkänna')
})
it('projects the tool names inside the widget HTML for the accounted namespace', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'ui://pending-operations/app.html' }, 'accounted')
)
const result = await parseResult(res)
const html = result.contents[0].text as string
expect(html).toContain('accounted_approve_pending_operation')
expect(html).toContain('accounted_reject_pending_operation')
expect(html).not.toContain('gnubok_approve_pending_operation')
})
})
})
+10 -2
View File
@@ -14417,7 +14417,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_pending_operations',
title: 'List Pending Operations',
description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Use to review the queue before calling gnubok_approve_pending_operation or gnubok_reject_pending_operation.',
description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Approve via gnubok_approve_pending_operation, discard via gnubok_reject_pending_operation. render_ui=true opens the approval widget.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -14427,11 +14427,19 @@ export const tools: McpTool[] = [
operation_type: { type: 'string', description: 'Filter to a single operation_type (e.g. "create_invoice")' },
limit: { type: 'number', minimum: 1, maximum: 200, description: 'Default 50' },
offset: { type: 'number', minimum: 0, description: 'Default 0' },
render_ui: {
type: 'boolean',
description: 'Render the interactive approval widget (claude.ai / Desktop): approve/reject by click; the click supplies the high-risk BFL acknowledgment. Data returned either way. Default false.',
},
},
required: [],
},
outputSchema: paginatedSchema('operations'),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
// Renders the approval-queue widget only when the caller passes
// render_ui=true (the dispatcher emits result-level _meta in that case),
// keeping the tool data-only by default.
uiResourceUri: 'ui://pending-operations/app.html',
async execute(args, companyId, _userId, supabase) {
const status = (args.status as string) ?? 'pending'
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50))
@@ -15949,7 +15957,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.',
'• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.',
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.',
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget and gnubok_receipt_matcher opens the receipt↔transaction matcher. Both also return structured data; other clients ignore the UI and use the data.',
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget, gnubok_receipt_matcher opens the receipt↔transaction matcher, and gnubok_list_pending_operations(render_ui=true) opens the approval queue where the user approves/rejects with a click. All also return structured data; other clients ignore the UI and use the data.',
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
@@ -1,10 +1,12 @@
import type { UiWidget } from './types'
import { receiptMatcherWidget } from './receipt-matcher'
import { vatReviewWidget } from './vat-review'
import { pendingOperationsWidget } from './pending-operations'
export const uiWidgets: UiWidget[] = [
receiptMatcherWidget,
vatReviewWidget,
pendingOperationsWidget,
]
export function findUiWidget(uri: string): UiWidget | null {
@@ -0,0 +1,379 @@
import type { UiWidget } from './types'
/**
* Pending Operations Widget: MCP Apps inline HTML.
* The approval queue for staged operations, rendered in the conversation.
* Approve/reject are human CLICKS inside the widget, so the positive
* acknowledgment for high-risk operations (BFL 5 kap 5§) is first-party
* instead of agent-asserted: the widget arms the approve button and the
* second click sends confirmed=true.
* Triggered by gnubok_list_pending_operations with render_ui=true.
*/
export const PENDING_OPERATIONS_HTML = `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Att godkänna - Accounted</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--border: rgba(0,0,0,0.1);
--text: #1a1a1a;
--text-muted: #6b6b6b;
--success: #5a7a5a;
--success-bg: rgba(90,122,90,0.08);
--error: #b35a3a;
--error-bg: rgba(179,90,58,0.08);
--warn: #a5813c;
--warn-bg: rgba(165,129,60,0.1);
--accent: #3b3b3b;
}
.dark {
--bg: #161616;
--surface: #1e1e1e;
--border: rgba(255,255,255,0.1);
--text: #e5e5e5;
--text-muted: #999;
--success: #7aab7a;
--success-bg: rgba(122,171,122,0.1);
--error: #d4816a;
--error-bg: rgba(212,129,106,0.1);
--warn: #c9a45e;
--warn-bg: rgba(201,164,94,0.12);
--accent: #ccc;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
padding: 12px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 15px; font-weight: 600; }
.counter { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.loading { text-align: center; padding: 32px; color: var(--text-muted); }
.empty { text-align: center; padding: 32px; color: var(--text-muted); }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; font-weight: 500; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-muted); padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
td { padding: 8px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr.committed { background: var(--success-bg); }
tr.rejected td { color: var(--text-muted); }
tr.rejected .title { text-decoration: line-through; }
tr.error-row { background: var(--error-bg); }
.title { cursor: pointer; }
.title:hover { text-decoration: underline; }
.op-type { font-size: 11px; color: var(--text-muted); }
.date { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--text-muted); }
.chip {
display: inline-block; font-size: 11px; padding: 1px 8px;
border-radius: 99px; border: 1px solid var(--border);
color: var(--text-muted); white-space: nowrap;
}
.chip.high { color: var(--error); border-color: var(--error); background: var(--error-bg); }
.chip.medium { color: var(--warn); border-color: var(--warn); background: var(--warn-bg); }
.actions { text-align: right; white-space: nowrap; }
button {
font-size: 12px; padding: 4px 12px; border-radius: 99px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text); cursor: pointer; margin-left: 6px;
}
button:hover { background: var(--bg); }
button:disabled { opacity: 0.5; cursor: default; }
button.approve { border-color: var(--success); color: var(--success); }
button.approve.armed { background: var(--error-bg); border-color: var(--error); color: var(--error); font-weight: 600; }
button.reject { color: var(--text-muted); }
.check { color: var(--success); font-weight: 600; }
.status-note { font-size: 11px; color: var(--text-muted); }
.error-msg { color: var(--error); font-size: 11px; margin-top: 2px; }
.preview-row td { background: var(--bg); padding: 8px 12px; }
.preview-row pre {
font-size: 11px; white-space: pre-wrap; word-break: break-word;
max-height: 220px; overflow-y: auto; color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.arm-note { font-size: 11px; color: var(--error); display: block; margin-top: 2px; }
</style>
</head>
<body>
<div class="header">
<h1>Att godkänna</h1>
<span class="counter" id="counter"></span>
</div>
<div id="content"><div class="loading">Laddar väntande operationer…</div></div>
<script>
(function() {
// ── MCP Apps Bridge ──
let rpcId = 1;
const pending = new Map();
let operations = [];
let handled = 0;
// A host that never answers must not strand a row in "Arbetar..." with its
// buttons gone: time the RPC out so the catch path restores the row and
// the user can retry. 30s covers slow commits (journal posting, emails).
const RPC_TIMEOUT_MS = 30000;
function sendRequest(method, params) {
const id = rpcId++;
return new Promise(function(resolve, reject) {
const timer = setTimeout(function() {
if (pending.has(id)) {
pending.delete(id);
reject(new Error('Inget svar fr\\u00e5n v\\u00e4rden inom 30 sekunder. F\\u00f6rs\\u00f6k igen.'));
}
}, RPC_TIMEOUT_MS);
pending.set(id, {
resolve: function(v) { clearTimeout(timer); resolve(v); },
reject: function(e) { clearTimeout(timer); reject(e); }
});
window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params }, '*');
});
}
function sendNotification(method, params) {
window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params }, '*');
}
function callTool(name, args) {
return sendRequest('tools/call', { name: name, arguments: args });
}
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || msg.jsonrpc !== '2.0') return;
if (msg.id != null && pending.has(msg.id)) {
const entry = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) entry.reject(msg.error);
else entry.resolve(msg.result);
return;
}
if (msg.method === 'ui/notifications/tool-result') {
const sc = msg.params && msg.params.structuredContent;
if (sc && sc.operations) {
operations = sc.operations;
handled = 0;
render();
}
return;
}
if (msg.method === 'ui/notifications/tool-input') return;
if (msg.method === 'ui/notifications/host-context-changed') {
applyTheme(msg.params);
return;
}
});
function applyTheme(ctx) {
if (!ctx) return;
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
}
// ── Initialize ──
sendRequest('ui/initialize', {
name: 'gnubok-pending-operations',
version: '1.0.0'
}).then(function(res) {
if (res && res.hostContext) applyTheme(res.hostContext);
sendNotification('ui/notifications/initialized');
}).catch(function() {
sendNotification('ui/notifications/initialized');
});
// ── Render ──
function render() {
const el = document.getElementById('content');
const counterEl = document.getElementById('counter');
counterEl.textContent = handled + ' av ' + operations.length + ' hanterade';
if (!operations.length) {
el.innerHTML = '<div class="empty">Inga v\\u00e4ntande operationer.</div>';
return;
}
let html = '<table><thead><tr>' +
'<th>Skapad</th><th>\\u00c5tg\\u00e4rd</th><th>Risk</th><th></th>' +
'</tr></thead><tbody>';
operations.forEach(function(op, i) {
const cls = op._done === 'committed' ? 'committed' : (op._done === 'rejected' ? 'rejected' : (op._error ? 'error-row' : ''));
html += '<tr class="' + cls + '" data-idx="' + i + '">';
html += '<td class="date">' + esc((op.created_at || '').slice(0, 10)) + '</td>';
html += '<td><span class="title" data-toggle="' + i + '">' + esc(op.title || op.operation_type || '') + '</span>' +
'<div class="op-type">' + esc(op.operation_type || '') + (op.actor_label ? ' \\u00b7 ' + esc(op.actor_label) : '') + '</div>' +
(op._error ? '<div class="error-msg">' + esc(op._error) + '</div>' : '') +
'</td>';
html += '<td>' + riskChip(op.risk_level) + '</td>';
html += '<td class="actions">' + actionCell(op, i) + '</td>';
html += '</tr>';
if (op._expanded && op.preview_data) {
html += '<tr class="preview-row"><td colspan="4"><pre>' +
esc(JSON.stringify(op.preview_data, null, 2)) + '</pre></td></tr>';
}
});
html += '</tbody></table>';
el.innerHTML = html;
document.querySelectorAll('[data-toggle]').forEach(function(t) {
t.addEventListener('click', function() {
const idx = parseInt(t.dataset.toggle);
operations[idx]._expanded = !operations[idx]._expanded;
render();
});
});
document.querySelectorAll('button[data-approve]').forEach(function(b) {
b.addEventListener('click', function() { approve(parseInt(b.dataset.approve)); });
});
document.querySelectorAll('button[data-reject]').forEach(function(b) {
b.addEventListener('click', function() { reject(parseInt(b.dataset.reject)); });
});
}
function riskChip(level) {
if (level === 'high') return '<span class="chip high">h\\u00f6g</span>';
if (level === 'medium') return '<span class="chip medium">medel</span>';
return '<span class="chip">l\\u00e5g</span>';
}
function actionCell(op, i) {
if (op._done === 'committed') return '<span class="check">\\u2713 Godk\\u00e4nd</span>';
if (op._done === 'rejected') return '<span class="status-note">Avvisad</span>';
if (op._working) return '<span class="status-note">Arbetar\\u2026</span>';
let html = '';
if (op._armed) {
// Second click IS the positive BFL 5 kap 5\\u00a7 acknowledgment: it
// sends confirmed=true. First-party human click, not agent-asserted.
html += '<button class="approve armed" data-approve="' + i + '">Bekr\\u00e4fta bokf\\u00f6ring</button>';
html += '<span class="arm-note">O\\u00e5terkallelig enligt BFL. Klicka igen f\\u00f6r att bekr\\u00e4fta.</span>';
} else {
html += '<button class="reject" data-reject="' + i + '">Avvisa</button>';
html += '<button class="approve" data-approve="' + i + '">Godk\\u00e4nn</button>';
}
return html;
}
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
function parseResult(res) {
if (res && res.structuredContent) return res.structuredContent;
if (res && res.content && res.content[0]) {
try { return JSON.parse(res.content[0].text); } catch (e) { return {}; }
}
return {};
}
function errMsg(result, fallback) {
if (result && result.error && typeof result.error === 'object') {
return result.error.message_sv || result.error.message_en || fallback;
}
if (result && typeof result.error === 'string') return result.error;
return null;
}
// ── Actions ──
function approve(idx) {
const op = operations[idx];
if (op._done || op._working) return;
if (op.risk_level === 'high' && !op._armed) {
op._armed = true;
render();
return;
}
op._working = true;
op._armed = false;
op._error = null;
render();
const args = { operation_id: op.id };
if (op.risk_level === 'high') args.confirmed = true;
callTool('gnubok_approve_pending_operation', args).then(function(res) {
op._working = false;
const result = parseResult(res);
const err = errMsg(result, 'Kunde inte godk\\u00e4nna operationen.');
if (result.status === 'committed') {
op._done = 'committed';
handled++;
notifyProgress('Godk\\u00e4nde "' + (op.title || op.operation_type) + '" via widgeten.');
} else {
op._error = err || 'Kunde inte godk\\u00e4nna operationen (status: ' + (result.status || 'ok\\u00e4nd') + ').';
}
render();
}).catch(function(err) {
op._working = false;
op._error = (err && err.message) || 'Kunde inte godk\\u00e4nna operationen.';
render();
});
}
function reject(idx) {
const op = operations[idx];
if (op._done || op._working) return;
op._working = true;
op._error = null;
render();
callTool('gnubok_reject_pending_operation', {
operation_id: op.id,
reason: 'Avvisad i granskningswidgeten'
}).then(function(res) {
op._working = false;
const result = parseResult(res);
const err = errMsg(result, 'Kunde inte avvisa operationen.');
if (err) {
op._error = err;
} else {
op._done = 'rejected';
handled++;
notifyProgress('Avvisade "' + (op.title || op.operation_type) + '" via widgeten.');
}
render();
}).catch(function(err) {
op._working = false;
op._error = (err && err.message) || 'Kunde inte avvisa operationen.';
render();
});
}
function notifyProgress(line) {
sendNotification('ui/updateContext', {
content: [{ type: 'text', text: line + ' ' + handled + ' av ' + operations.length + ' hanterade.' }]
});
}
})();
</script>
</body>
</html>`
export const pendingOperationsWidget: UiWidget = {
uri: 'ui://pending-operations/app.html',
name: 'Pending Operations',
description: 'Interactive approval queue for staged operations: approve or reject with a human click',
html: PENDING_OPERATIONS_HTML,
}