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:
co-authored by
Claude Fable 5
parent
dc5aea4a35
commit
4501f118c2
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user