Skip to content

fix(webui): retain tool permission selection while saving - #6339

Merged
think-in-universe merged 10 commits into
mainfrom
issue-6331-tool-permission-optimistic-state
Jul 21, 2026
Merged

think-in-universe merged 10 commits into
mainfrom
issue-6331-tool-permission-optimistic-state

Conversation

@italic-jinxin

@italic-jinxin italic-jinxin commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • retain the selected tool permission while its save request is pending
  • scope concurrent saves by request ID and reconcile stale responses safely
  • refetch canonical tool settings after failed or timed-out saves
  • bound permission save requests with a 30-second timeout
  • clean up Saved indicator timers on unmount
  • replace the brittle E2E fixed delay with an observable pending-state assertion
iShot_2026-07-20_18 19 06

Change Type

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • CI/Infrastructure
  • Security
  • Dependencies

Linked Issue

Closes #6331

Validation

  • TZ=UTC ./node_modules/.bin/vitest run
  • ./node_modules/.bin/tsc --noEmit
  • node --experimental-strip-types scripts/check-source-conventions.ts
  • ./node_modules/.bin/vite build
  • cargo test -p ironclaw_webui --all-features
  • cargo clippy -p ironclaw_webui --all-features --all-targets -- -D warnings
  • Playwright pending-save regression
  • scripts/pre-commit-safety.sh

Security Impact

None. This changes only client-side presentation state while using the existing authenticated tool-permission API.

Reborn Trust-Boundary Checklist

N/A. No trust boundary, authorization, runtime, or persistence behavior changed.

Database Impact

None. No schema, migration, or persistence changes.

Blast Radius

Limited to the WebUI Settings → Tools permission dropdown and its frontend test coverage.

Rollback Plan

Revert the two commits to restore the previous server-value-only rendering behavior and remove the associated regression coverage.

Review Follow-Through

The pending, success, failure rollback, and stale-response paths were reviewed. No blocking findings or additional follow-up work were identified.


Review track: B

@italic-jinxin italic-jinxin added size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jul 20, 2026
@ironloopai

ironloopai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: fe27e79493eebb5afd29b2cbd512d3ee5b7450aa
Result: One or more review results were superseded by a newer PR head.
Next: Run @ironloopai review on the latest PR head.
Updated: 2026-07-21T11:10:22.363Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Superseded N/A N/A 2026-07-20T10:25:37.319Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Superseded by a newer PR head. New head: c535001. Previous verdict: Changes requested.
Recent activity
Time Reviewer State Detail
2026-07-20T09:27:49.749Z ironloop/common-reviewer (reviewer) Superseded A newer PR head replaced this review (1ac3fb4).
2026-07-20T10:20:15.118Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head f329dbb.
2026-07-20T10:20:15.118Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-20T10:20:15.969Z ironloop/common-reviewer (reviewer) Started Reviewer worker started.
2026-07-20T10:20:18.330Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (head_ref) at f329dbb.
2026-07-20T10:23:51.510Z ironloop/common-reviewer (reviewer) Result captured Changes requested; 1 blocking finding.
2026-07-20T10:23:51.510Z ironloop/common-reviewer (reviewer) Completed Review completed and terminal status was persisted.
2026-07-20T10:25:37.319Z ironloop/common-reviewer (reviewer) Superseded A newer PR head replaced this review (c535001).
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
Run metadata

Admission: webhook accepted the request and IronLoop persisted reviewer state before this projection.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 08:46 Destroyed
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f7a2fa50-05a9-46d9-924c-731796ec8ff1

📥 Commits

Reviewing files that changed from the base of the PR and between e74ff80 and 6fd55d2.

📒 Files selected for processing (5)
  • crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts
  • tests/e2e/helpers.py
  • tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py

📝 Walkthrough

Summary by CodeRabbit

  • Improvements
    • Tool permission selections now preserve the user’s chosen value while that tool’s permission is being saved, including a per-tool busy state.
    • Permission updates are resilient to rapid successive changes (stale responses won’t replace newer selections).
    • Save failures roll back the selection and refresh the displayed settings.
    • Permission update requests time out (and abort) if they exceed the configured limit, and successful saves briefly show a confirmation indicator.
  • Tests
    • Expanded unit and end-to-end coverage for pending selection behavior, rollbacks, safe handling of out-of-order results, and timed-out requests.

Walkthrough

Tool permission saves now retain the selected pending value, expose a busy state, gate concurrent responses by request ID, validate persisted responses, abort requests after 30 seconds, and add unit and Reborn E2E coverage.

Changes

Tool permission save flow

Layer / File(s) Summary
Per-tool pending state and concurrency
crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts, crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts
useTools tracks request IDs and pending permissions, handles stale outcomes and rollback invalidation, manages saved indicators and timers, and tests these behaviors.
Pending permission rendering
crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.tsx, crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.test.ts
ToolRow displays pending permission values, marks the select busy, receives per-tool pending state, and tests the wiring.
Permission response validation and cancellation
crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts, crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.test.ts
Permission updates use a 30-second abort timeout and validate persisted response shape and confirmed state, with tests for timeout and accepted or rejected responses.
Delayed-save end-to-end validation
tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py, tests/e2e/helpers.py, tests/e2e/reborn_coverage_tests.txt
The mocked save endpoint supports synchronization and delay; E2E coverage verifies retained selection, busy state, payload, and saved status.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant ToolRow
  participant useTools
  participant updateToolPermission
  participant SettingsAPI
  User->>ToolRow: Select new permission
  ToolRow->>useTools: setPermission(tool, state)
  useTools-->>ToolRow: Pending state and aria-busy
  useTools->>updateToolPermission: Save with requestId
  updateToolPermission->>SettingsAPI: POST permission update
  SettingsAPI-->>updateToolPermission: Response or timeout
  updateToolPermission-->>useTools: Success or error
  useTools-->>ToolRow: Saved state or rollback
Loading

Possibly related PRs

Suggested reviewers: ilblackdragon

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is Conventional Commits-style and accurately summarizes the main UI behavior change.
Description check ✅ Passed The description follows the repository template and covers the required sections with concrete details.
Linked Issues check ✅ Passed The changes satisfy #6331 by retaining the selected permission while pending and restoring state on failure or timeout.
Out of Scope Changes check ✅ Passed The changes stay within the tool-permission save flow and its test coverage, with no unrelated scope visible.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@italic-jinxin

Copy link
Copy Markdown
Contributor Author

@claude review

gemini-code-assist[bot]

This comment was marked as resolved.

ironloopai[bot]

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@railway-app

railway-app Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-6339 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw 🕒 Building (View Logs) Web Jul 21, 2026 at 11:57 am

@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 86.36% (319873 / 370378 lines)
  floor:    85.3% (tolerance 0.5pp -> effective floor 84.8%)
  denominator: 370378 lines now vs 320188 at floor capture (+50190 lines, +15.68%) — material change (>5%)

⚠️ 2 Reborn crate(s) have 0 int-tier coverage (target: 0) — ironclaw_prompt_envelope, ironclaw_scripts

Reborn integration-tier coverage

Line coverage (Reborn crates): 86.36% — 319873 / 370378 lines

Per-crate breakdown (65 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_prompt_envelope 0% 0 / 88
ironclaw_scripts 0% 0 / 345
ironclaw_event_projections 43.31% 673 / 1554
ironclaw_observability 61.54% 16 / 26
ironclaw_channel_host 62.08% 185 / 298
ironclaw_authorization 62.46% 604 / 967
ironclaw_dispatcher 62.88% 83 / 132
ironclaw_mcp 65.63% 611 / 931
ironclaw_filesystem 68.64% 4139 / 6030
ironclaw_memory 69.2% 773 / 1117
ironclaw_reborn_migration 71.87% 2407 / 3349
ironclaw_trust 72.88% 661 / 907
ironclaw_wasm_limiter 74.6% 47 / 63
ironclaw_extractors 74.72% 538 / 720
ironclaw_capabilities 74.79% 2513 / 3360
ironclaw_projects 76.48% 400 / 523
ironclaw_reborn_cli 76.54% 9890 / 12922
ironclaw_triggers 77.33% 2531 / 3273
ironclaw_llm 78.43% 20568 / 26224
ironclaw_product_context 78.57% 11 / 14
ironclaw_wasm_product_adapters 80.36% 1448 / 1802
ironclaw_process_sandbox 80.65% 671 / 832
ironclaw_first_party_extensions 81.06% 5965 / 7359
ironclaw_memory_native 81.17% 3195 / 3936
ironclaw_events 81.95% 1594 / 1945
ironclaw_telegram_extension 82.08% 4414 / 5378
ironclaw_network 82.98% 673 / 811
ironclaw_reborn_event_store 83.03% 1169 / 1408
ironclaw_reborn_identity 83.59% 433 / 518
ironclaw_processes 83.76% 939 / 1121
ironclaw_secrets 83.79% 2548 / 3041
ironclaw_wasm 84.44% 1069 / 1266
ironclaw_reborn_config 84.66% 2152 / 2542
ironclaw_product_workflow 84.75% 11088 / 13083
ironclaw_auth 84.97% 3279 / 3859
ironclaw_run_state 85.61% 458 / 535
ironclaw_channel_delivery 86.11% 1383 / 1606
ironclaw_common 86.66% 1741 / 2009
ironclaw_threads 87.08% 4844 / 5563
ironclaw_skills 87.58% 4470 / 5104
ironclaw_slack_v2_adapter 87.89% 2024 / 2303
ironclaw_extensions 87.93% 2913 / 3313
ironclaw_product_adapter_registry 88.06% 531 / 603
ironclaw_product_adapters 88.1% 3384 / 3841
ironclaw_reborn_traces 88.2% 11946 / 13544
ironclaw_turns 88.48% 14408 / 16284
ironclaw_hooks 88.62% 9597 / 10829
ironclaw_reborn_openai_compat 88.79% 3778 / 4255
ironclaw_host_runtime 88.8% 17752 / 19991
ironclaw_host_api 88.88% 4645 / 5226
ironclaw_webui 89.33% 7700 / 8620
ironclaw_reborn_composition 89.53% 73202 / 81767
ironclaw_telegram_v2_adapter 89.65% 2712 / 3025
ironclaw_approvals 90.18% 1598 / 1772
ironclaw_conversations 90.39% 3123 / 3455
ironclaw_event_streams 90.82% 1009 / 1111
ironclaw_runner 91.2% 17007 / 18649
ironclaw_resources 91.67% 4477 / 4884
ironclaw_loop_host 92.29% 16104 / 17450
ironclaw_attachments 93.06% 630 / 677
ironclaw_agent_loop 94.81% 9467 / 9985
ironclaw_safety 95.15% 3749 / 3940
ironclaw_outbound 95.52% 3451 / 3613
ironclaw_first_party_extension_ports 95.62% 3672 / 3840
ironclaw_runtime_policy 96.55% 811 / 840

This table itself is informational and never gates the PR on its own — not the percentage, not the per-crate holes, not the 0-coverage callout. A separate coverage ratchet (dry-run until enforce=true; see tests/integration/coverage-floor.toml) can fail the build on specific configured floors.

Exemptions (3 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_embeddings v1-only: consumed only by root ironclaw (src/app.rs, src/tools/builtin/memory.rs, src/workspace/mod.rs, src/config/{mod,embeddings}.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_gateway v1-only: consumed only by root ironclaw (src/channels/web/platform/static_files.rs, src/channels/web/handlers/frontend.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_tui v1-only: consumed only by root ironclaw (src/main.rs, src/channels/tui.rs); no crates/* dependents. Crate's own doc comment confirms it bridges INTO v1, not Reborn. Covered by "Tests (Legacy)". #5657

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 09:27 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 09:44 Destroyed
@italic-jinxin italic-jinxin added the human-verified Manually tested and verified label Jul 20, 2026
@italic-jinxin
italic-jinxin marked this pull request as ready for review July 20, 2026 10:20
ironloopai[bot]

This comment was marked as resolved.

…ssion-optimistic-state

# Conflicts:
#	crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.test.ts
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 10:25 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts (1)

47-68: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Cache write in onSuccess isn't guarded against out-of-order stale completions.

queryClient.setQueryData runs unconditionally at Line 48, before the staleness check at Line 58 (pendingRequestIds.current[name] !== requestId). That check only gates the pending/saved-indicator cleanup, not the cache write itself. Consider: user saves always_allow (id=1) then disabled (id=2). If id=2 resolves first (cache→disabled, pending cleared, "Saved" shown), and id=1's response arrives later (also success, since it did land on the server before being superseded), the cache is silently overwritten back to always_allow — a stale response regresses an already-confirmed newer value, with no follow-up reconciliation (unlike the error path, which calls invalidateQueries).

This is the same class of concurrency issue flagged in the earlier review (older-success/newer-failure ordering) — that case is now tested and handled via invalidateQueries on error, but the success/success out-of-order case is still open and untested.

🔒 Proposed fix: monotonic per-tool "latest applied" guard
+  const latestAppliedRequestId = React.useRef({});
+
   const mutation = useMutation({
     mutationFn: async ({ name, state }) =>
       throwIfApiFailed(await updateToolPermission(name, state), "Save failed"),
     onSuccess: (data, { name, state, requestId }) => {
+      if ((latestAppliedRequestId.current[name] || 0) > requestId) return;
+      latestAppliedRequestId.current[name] = requestId;
       queryClient.setQueryData(["settings-tools"], (old) => {

Please also add a regression test for the success-after-success out-of-order case (newer resolves first, older resolves later).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts` around
lines 47 - 68, Guard the cache update in the onSuccess handler against stale
out-of-order responses by tracking the latest applied request per tool and
ignoring any success whose requestId is older than that value; keep the existing
pending/saved-indicator guard and update the tracking state only when applying a
current response. Add a regression test covering the newer success resolving
first followed by the older success, asserting the cache retains the newer tool
state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.test.ts`:
- Around line 187-205: Add an integration test that renders ToolsTab using the
mocked useTools() data with pendingPermissions containing an entry for the tool
name, then locate the resulting ToolRow and assert its pendingPermission prop
matches the entry’s state. Update the renderToolsModule mock setup as needed
while preserving the existing direct ToolRow coverage.

In `@crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts`:
- Around line 163-177: Update updateToolPermission to validate that the API
response contains a well-formed persisted entry before returning success,
treating a missing or malformed data.entry as an error rather than passing null
or undefined downstream. Ensure failures prevent optimistic success handling,
and add a test covering responses with missing and malformed entries.

In `@tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py`:
- Around line 358-360: Extract the inline settings-tool-permission selector from
the test around _tool_row into the shared SEL_V2 selector definitions, then
update the locator to reference that SEL_V2 symbol. Preserve the existing
selector value and test behavior.

---

Outside diff comments:
In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts`:
- Around line 47-68: Guard the cache update in the onSuccess handler against
stale out-of-order responses by tracking the latest applied request per tool and
ignoring any success whose requestId is older than that value; keep the existing
pending/saved-indicator guard and update the tracking state only when applying a
current response. Add a regression test covering the newer success resolving
first followed by the older success, asserting the cache retains the newer tool
state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 217a7971-1716-481f-890e-7527e11a4997

📥 Commits

Reviewing files that changed from the base of the PR and between c291098 and f329dbb.

📒 Files selected for processing (8)
  • crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/components/tools-tab.tsx
  • crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts
  • tests/e2e/reborn_coverage_tests.txt
  • tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py

Comment thread tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts (1)

204-213: ⚠️ Potential issue | 🔴 Critical

Missing backend evidence validation on save.

Violates crates/ironclaw_webui/** invariant: "Capability completion may be claimed only from the structured outcome and admitted evidence; empty output is an error unless explicitly allowed by the contract." updateToolPermission returns success even if data.entry is missing, allowing an optimistic success state without durable backend evidence.

🛡️ Proposed fix
     const data = await apiFetch(`${SETTINGS_TOOLS_BASE}/${encodeURIComponent(name)}`, {
       method: "POST",
       body: JSON.stringify({ state: normalized }),
       signal: controller.signal,
     });
+    if (!data?.entry) {
+      throw new Error("Save did not return a persisted tool entry");
+    }
     return { success: true, tool: toolFromConfigEntry(data.entry), entry: data.entry };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts` around
lines 204 - 213, Update updateToolPermission to validate that the POST response
contains a valid data.entry before returning success; treat a missing or empty
entry as an error and preserve the existing timeout cleanup. Only construct
toolFromConfigEntry and claim success after backend evidence is present.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts`:
- Around line 204-213: Update updateToolPermission to validate that the POST
response contains a valid data.entry before returning success; treat a missing
or empty entry as an error and preserve the existing timeout cleanup. Only
construct toolFromConfigEntry and claim success after backend evidence is
present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6c08435a-2d01-439f-b0f9-725cf77a91b6

📥 Commits

Reviewing files that changed from the base of the PR and between f329dbb and c535001.

📒 Files selected for processing (1)
  • crates/ironclaw_webui/frontend/src/pages/settings/lib/settings-api.ts

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 10:34 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts (1)

78-100: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Concurrent saves swallow per-tool error state.

Because useMutation tracks state globally across the hook, if a user modifies Tool B while Tool A's save is still pending, the new mutation supersedes the active observer state. If Tool A subsequently fails, mutation.error is not updated, and the UI silently swallows the error.

As per coding guidelines, do not lose evidence in UI results. Manage the error state independently of the React Query mutation so that stale failures on concurrent saves are reliably surfaced.

🐛 Proposed fix for `useTools.ts`
@@ -15,6 +15,7 @@
 
   const [savedTools, setSavedTools] = React.useState({});
   const [pendingPermissions, setPendingPermissions] = React.useState({});
+  const [saveError, setSaveError] = React.useState(null);
   const nextRequestId = React.useRef(0);
   const pendingRequestIds = React.useRef({});
   const savedTimeouts = React.useRef({});
@@ -71,9 +72,12 @@
     },
     onError: (_error, { name, requestId }) => {
+      const isLatest = pendingRequestIds.current[name] === requestId;
       clearPendingPermission(name, requestId);
       queryClient.invalidateQueries({ queryKey: ["settings-tools"] });
+      if (isLatest) {
+        setSaveError(_error);
+      }
     },
   });
 
@@ -82,6 +86,7 @@
       const requestId = nextRequestId.current + 1;
       nextRequestId.current = requestId;
       pendingRequestIds.current[name] = requestId;
       mutation.reset();
+      setSaveError(null);
       setPendingPermissions((prev) => ({
         ...prev,
         [name]: { requestId, state },
@@ -97,7 +102,7 @@
     setPermission,
     savedTools,
     pendingPermissions,
-    error: mutation.error,
+    error: saveError,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts` around
lines 78 - 100, Update the useTools hook’s setPermission flow to track save
errors independently for each tool/request instead of relying on the shared
mutation.error state. Preserve request IDs so concurrent failures, including
stale failures from earlier saves, are recorded and surfaced for the
corresponding tool without being overwritten by later mutations.

Source: Coding guidelines

crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts (1)

81-176: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update assertions to filter for function updaters.

If useTools.ts is fixed to track saveError independently using a direct value, multiple tests will break because calls.filter(...) for setState will mistakenly capture the setSaveError value (null or Error) instead of the setPendingPermissions updater function, resulting in TypeError: updater is not a function.

Filter for function updaters to ensure the correct state update is asserted.

🛠️ Proposed fix
@@ -90,7 +90,7 @@
   tools.setPermission("builtin.echo", "disabled");
 
-  const pendingUpdate = calls.find((call) => call.type === "setState");
+  const pendingUpdate = calls.find((call) => call.type === "setState" && typeof call.updater === "function");
   assert.ok(pendingUpdate, "expected the pending permission to update immediately");
@@ -104,7 +104,7 @@
   tools.setPermission("builtin.echo", "disabled");
-  const pendingPermission = calls.find((call) => call.type === "setState").updater({});
+  const pendingPermission = calls.find((call) => call.type === "setState" && typeof call.updater === "function").updater({});
   const variables = calls.find((call) => call.type === "mutate").payload;
 
   getMutationOptions().onError(new Error("permission denied"), variables);
 
-  const rollbackUpdate = calls.filter((call) => call.type === "setState").at(-1);
+  const rollbackUpdate = calls.filter((call) => call.type === "setState" && typeof call.updater === "function").at(-1);
   assert.deepEqual(JSON.parse(JSON.stringify(rollbackUpdate.updater(pendingPermission))), {});
@@ -124,7 +124,7 @@
   tools.setPermission("builtin.echo", "disabled");
   const mutations = calls.filter((call) => call.type === "mutate");
-  const pendingUpdates = calls.filter((call) => call.type === "setState");
+  const pendingUpdates = calls.filter((call) => call.type === "setState" && typeof call.updater === "function");
   const pendingPermissions = pendingUpdates.reduce(
@@ -138,7 +138,7 @@
   getMutationOptions().onError(new Error("permission denied"), mutations[1].payload);
 
-  const rollbackUpdate = calls.filter((call) => call.type === "setState").at(-1);
+  const rollbackUpdate = calls.filter((call) => call.type === "setState" && typeof call.updater === "function").at(-1);
   assert.deepEqual(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts`
around lines 81 - 176, Update the setState call selections in the
permission-save tests, especially “failed permission saves clear the pending
selection...” and “a newer failed save...” to filter for entries whose updater
is a function before invoking updater. Keep assertions focused on the
pending-permissions updater and avoid capturing direct saveError state values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts`:
- Around line 81-176: Update the setState call selections in the permission-save
tests, especially “failed permission saves clear the pending selection...” and
“a newer failed save...” to filter for entries whose updater is a function
before invoking updater. Keep assertions focused on the pending-permissions
updater and avoid capturing direct saveError state values.

In `@crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts`:
- Around line 78-100: Update the useTools hook’s setPermission flow to track
save errors independently for each tool/request instead of relying on the shared
mutation.error state. Preserve request IDs so concurrent failures, including
stale failures from earlier saves, are recorded and surfaced for the
corresponding tool without being overwritten by later mutations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5a6d8cc7-1284-4619-b492-e80237548082

📥 Commits

Reviewing files that changed from the base of the PR and between c535001 and e74ff80.

📒 Files selected for processing (2)
  • crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.test.ts
  • crates/ironclaw_webui/frontend/src/pages/settings/hooks/useTools.ts

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 10:51 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 20, 2026 10:55 Destroyed
@italic-jinxin

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Code Review: PR #6339

Reviewed by: Security & Safety, Architecture & Patterns, Bug Scan, and Performance & Production agents

Summary

No critical issues found. The PR implements optimistic UI state for tool permission changes with strong defensive design, comprehensive test coverage, and proper error handling.

Key Strengths

  • ✅ Stale Response Prevention: Request ID tracking (via nextRequestId and pendingRequestIds) correctly prevents race conditions where older responses could overwrite newer saves
  • ✅ API Response Validation: persistedToolFromConfigEntry() exhaustively validates response shape, enum membership, and confirms persisted state matches requested state
  • ✅ Network Timeout: 30-second bounded timeout with proper cleanup via finally block; no resource exhaustion risk
  • ✅ Error Recovery: On failure, pending state is cleared and server state is re-fetched via invalidateQueries
  • ✅ Memory Safety: All timeouts cleaned up on component unmount; no dangling timers or callbacks
  • ✅ Test Coverage: Comprehensive unit tests, component integration tests, and E2E test with delayed responses; test-through-the-caller pattern followed
  • ✅ Accessibility: aria-busy attribute signals loading state to assistive technologies
  • ✅ No Type Confusion: States validated against whitelisted enum sets; proper URL encoding for tool names

Minor Observations (Not Issues)

  • Type safety: Request ID mapping uses plain objects keyed by tool name; could benefit from explicit type definition for clarity, but logic is sound
  • Test coverage: Early exception paths in updateToolPermission() (e.g., JSON stringify failure) not explicitly tested, though mitigated by finally-block cleanup
  • Request ID overflow: Unguarded against JavaScript's Number.MAX_SAFE_INTEGER, but probability is astronomically low in practice (9+ quadrillion operations)

Verification

  • ✅ No security vulnerabilities or auth bypass
  • ✅ No panics or unwrap/expect in production code
  • ✅ No N+1 queries or unbounded allocations
  • ✅ React hook dependencies and effects correctly configured
  • ✅ All resource leaks mitigated; cleanup on unmount

Result: Approved — production ready.

@italic-jinxin italic-jinxin self-assigned this Jul 20, 2026
@think-in-universe
think-in-universe added this pull request to the merge queue Jul 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 21, 2026
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6339 July 21, 2026 11:10 Destroyed
@think-in-universe
think-in-universe added this pull request to the merge queue Jul 21, 2026
Merged via the queue into main with commit e4626fd Jul 21, 2026
66 checks passed
@think-in-universe
think-in-universe deleted the issue-6331-tool-permission-optimistic-state branch July 21, 2026 12:32

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-6339 — fe27e794 Deployed Jul 21, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs human-verified Manually tested and verified risk: low Changes to docs, tests, or low-risk modules size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool permission dropdown temporarily reverts while saving

2 participants