From a0773ac71885adcc0676b2c79cdec41976a36b9a Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Fri, 24 Jul 2026 23:45:02 +0900
Subject: [PATCH] feat(webapp): batch-approve pending proposals from the review
queue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the pending queue could approve only one proposal at a time; clearing a
review backlog meant opening and approving each in turn. selection
checkboxes already existed but drove only page merges.
show a selection checkbox on every approvable row, add a "select all"
toggle and an "approve N selected" action that approves the checked rows
in one pass — client-side fan-out per project, the same shape as the
existing clear (reject-all) path, since there is no bulk-approve endpoint.
merge stays pages-only off the same selection set.
---
webapp/src/views/PendingView.multi.test.tsx | 23 +++++
webapp/src/views/PendingView.test.tsx | 12 ++-
webapp/src/views/PendingView.tsx | 96 +++++++++++++++++++--
3 files changed, 118 insertions(+), 13 deletions(-)
diff --git a/webapp/src/views/PendingView.multi.test.tsx b/webapp/src/views/PendingView.multi.test.tsx
index ee79c0f5..bbb293bc 100644
--- a/webapp/src/views/PendingView.multi.test.tsx
+++ b/webapp/src/views/PendingView.multi.test.tsx
@@ -77,3 +77,26 @@ test('approve routes to the project the proposal belongs to', async () => {
const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve')
expect(approveCalls).toHaveLength(1)
})
+
+test('batch approve: select all then approve fires kb.approve once per checked row', async () => {
+ renderWithProviders()
+ await screen.findByText('claim living in project a')
+ await userEvent.click(screen.getByRole('checkbox', { name: /select all pending/i }))
+ await userEvent.click(await screen.findByRole('button', { name: /approve 2 selected/i }))
+
+ await waitFor(() => {
+ const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve')
+ expect(approveCalls).toHaveLength(2)
+ })
+ // each approval routed to the project that owns the proposal
+ const approved = vi
+ .mocked(rpc)
+ .mock.calls.filter(([, m]) => m === 'kb.approve')
+ .map(([conn, , params]) => [conn.endpoint, (params as { proposal_id: string }).proposal_id])
+ expect(approved).toEqual(
+ expect.arrayContaining([
+ [TEST_ENDPOINT, 'prop-from-a'],
+ [TEST_ENDPOINT_B, 'prop-from-b'],
+ ]),
+ )
+})
diff --git a/webapp/src/views/PendingView.test.tsx b/webapp/src/views/PendingView.test.tsx
index 1a7e1df3..f52b4b35 100644
--- a/webapp/src/views/PendingView.test.tsx
+++ b/webapp/src/views/PendingView.test.tsx
@@ -151,10 +151,10 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m
throw new Error(`unexpected ${method}`)
})
renderWithProviders()
- await userEvent.click(await screen.findByLabelText('select prop-page-a for merge'))
+ await userEvent.click(await screen.findByLabelText('select prop-page-a'))
// one selection is not mergeable yet
expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument()
- await userEvent.click(screen.getByLabelText('select prop-page-b for merge'))
+ await userEvent.click(screen.getByLabelText('select prop-page-b'))
await userEvent.click(screen.getByRole('button', { name: /merge 2 into one/i }))
await waitFor(() =>
expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.merge_pending', {
@@ -164,7 +164,7 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m
expect(await screen.findByText(/merged 2 → prop-merged/i)).toBeInTheDocument()
})
-test('merge checkboxes are absent when kb.merge_pending is not advertised', async () => {
+test('selection checkbox drives batch-approve; merge button is absent without kb.merge_pending', async () => {
const pageA = {
...PROPOSAL,
id: 'prop-page-a',
@@ -174,7 +174,11 @@ test('merge checkboxes are absent when kb.merge_pending is not advertised', asyn
vi.mocked(rpc).mockResolvedValue([pageA])
renderWithProviders()
await screen.findByText(/prop-page-a/)
- expect(screen.queryByLabelText(/for merge/)).not.toBeInTheDocument()
+ // the row is approvable, so a selection checkbox is present for batch approve …
+ expect(screen.getByLabelText('select prop-page-a')).toBeInTheDocument()
+ // … but with kb.merge_pending unadvertised there is no merge action
+ await userEvent.click(screen.getByLabelText('select prop-page-a'))
+ expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument()
})
test('approve removes the proposal from the queue before the server responds (optimistic)', async () => {
diff --git a/webapp/src/views/PendingView.tsx b/webapp/src/views/PendingView.tsx
index f72fdbd9..09a244d8 100644
--- a/webapp/src/views/PendingView.tsx
+++ b/webapp/src/views/PendingView.tsx
@@ -113,12 +113,21 @@ export function PendingView() {
const clearTargets = rows.filter((r) => hasMethod('kb.reject', r.project.conn.endpoint))
const canClear = clearTargets.length > 0
// Intersect with the live queue: a proposal decided elsewhere mid-selection
- // must not be sent to merge. Merge combines proposals inside ONE KB — a
- // mixed-project selection cannot be merged.
+ // must not be acted on. Merge combines PAGES inside ONE KB; batch-approve can
+ // target any approvable row, across projects.
const checkedRows = rows.filter((r) => checked.has(rowKey(r)))
- const mergeProject = checkedRows[0]?.project ?? null
- const mergeSameProject = checkedRows.every((r) => r.project === mergeProject)
- const mergeIds = mergeSameProject ? checkedRows.map((r) => r.proposal.id) : []
+ const mergeRows = checkedRows.filter((r) => r.proposal.kind === 'page')
+ const mergeProject = mergeRows[0]?.project ?? null
+ const mergeSameProject = mergeRows.every((r) => r.project === mergeProject)
+ const mergeIds = mergeSameProject ? mergeRows.map((r) => r.proposal.id) : []
+ // Batch approve: every row whose endpoint advertises kb.approve is selectable;
+ // the action targets the checked subset.
+ const approvableRows = rows.filter((r) => hasMethod('kb.approve', r.project.conn.endpoint))
+ const approveTargets = approvableRows.filter((r) => checked.has(rowKey(r)))
+ const allApprovableChecked =
+ approvableRows.length > 0 && approveTargets.length === approvableRows.length
+ const canCheck = (r: Row) =>
+ hasMethod('kb.approve', r.project.conn.endpoint) || (canMerge && r.proposal.kind === 'page')
function toggleChecked(key: string) {
setChecked((prev) => {
@@ -129,6 +138,19 @@ export function PendingView() {
})
}
+ function toggleAllApprovable() {
+ setChecked((prev) => {
+ const next = new Set(prev)
+ const keys = approvableRows.map(rowKey)
+ const allOn = keys.length > 0 && keys.every((k) => next.has(k))
+ for (const k of keys) {
+ if (allOn) next.delete(k)
+ else next.add(k)
+ }
+ return next
+ })
+ }
+
function afterDecision() {
setSelectedKey(null)
setRejecting(false)
@@ -229,6 +251,26 @@ export function PendingView() {
},
})
+ // Batch approve: approve every checked approvable row. No bulk endpoint exists —
+ // loop client-side per project, the same shape as clear (reject-all).
+ const approveSelected = useMutation({
+ mutationFn: async () => {
+ const results = await Promise.allSettled(
+ approveTargets.map((r) =>
+ rpc(r.project.conn, 'kb.approve', { proposal_id: r.proposal.id }),
+ ),
+ )
+ const ok = results.filter((x) => x.status === 'fulfilled').length
+ return { ok, failed: results.length - ok }
+ },
+ onError: decisionFailed,
+ onSuccess: ({ ok, failed }) => {
+ toast(failed ? 'info' : 'success', `Approved ${ok}${failed ? ` (${failed} failed)` : ''}`)
+ setChecked(new Set())
+ afterDecision()
+ },
+ })
+
// Compile ingests ONE project's approved claims — it is offered when the
// scope names a single project (use the scope switcher to pick one).
const canCompile = !aggregated && !!conn && hasMethod('kb.compile')
@@ -378,7 +420,43 @@ export function PendingView() {
reject all {clearTargets.length} pending at once
))}
- {canMerge && checkedRows.length >= 2 && (
+ {approvableRows.length > 0 && (
+
+
+ {approveTargets.length >= 1 && (
+ <>
+
+
+ >
+ )}
+
+ )}
+ {canMerge && mergeRows.length >= 2 && (
{!mergeSameProject && (
pick pages from one project
@@ -402,11 +480,11 @@ export function PendingView() {