Skip to content

fix: stop multiple-joystick warning from repeatedly stealing control - #2799

Open
rafaellehmkuhl wants to merge 2 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-2798-multiple-joysticks-handling
Open

fix: stop multiple-joystick warning from repeatedly stealing control#2799
rafaellehmkuhl wants to merge 2 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-2798-multiple-joysticks-handling

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Jun 19, 2026

Copy link
Copy Markdown
Member

Summary

The "Multiple joystick controllers detected" warning is triggered when Cockpit detects another ground control station sending MANUAL_CONTROL/RC_CHANNELS_OVERRIDE to the vehicle (checkForOtherManualControlSources), not when two physical gamepads are plugged in. On busy test days with multiple computers/vehicles, that detection trips often.

The check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect (gamepad polling, Bluetooth roaming, etc.). Once another source was detected, enableForwarding was set to false — and since forwarding was now off, the next reconnect re-ran the check, re-disabled forwarding, and re-showed the dialog. That's the reported "popup keeps spawning" + "user loses control" loop. The only workaround was the joystick config view's onUnmounted hook force-enabling forwarding (the "twiddle the sticks and click out" trick).

This PR makes the conflict check run at most once per session, on the first joystick connection, and never again — so the joystick already in use keeps control and the dialog is shown at most once.

  • Track the check with an otherSourcesCheck stage (pendingrunningdone) owned by the check itself. Reconnections no longer re-run it, and the running stage doubles as the in-flight guard, so concurrent connection events can't race to show the dialog twice.
  • Keying the skip on the check rather than on enableForwarding matters: that ref is turned on with no joystick connected by ConfigurationJoystickView's onUnmounted hook and by the document-visibility watcher in the web build, so skipping on it would mean the safety check never runs at all for the first joystick.
  • Once the check is done, a reconnection restores forwarding through enableJoystickForwardingIfSafe() instead of re-checking. The disconnect cleanup disables forwarding whenever the last joystick goes away, so without this the sticks would stay silent after every replug. The helper refuses while preventJoystickForwarding is set, so a session where a conflict was found stays disabled until the user re-enables it by hand.
  • Second commit: the joystick settings view's onUnmounted hook wrote enableForwarding directly, bypassing that guard, so opening and leaving that page after the warning brought the sticks back live with nothing on screen saying so. It now calls enableJoystickForwardingIfSafe() too — it still restores forwarding in a clean session, and leaves it off in a conflicted one. Pre-existing hole rather than one this fix opens, but it is the same invariant, so it lands here.
  • Skip the check's automatic enable/disable if the user moved the forwarding switch while the check was in flight. getVehicleAddress() waits for the vehicle with no timeout, so the check can resolve minutes later, and a deliberate pause or resume made in the meantime should not be undone. The gesture is stamped by a new setForwardingByUser store action, called by the top-right joystick widget's switch — the only user-facing writer of enableForwarding. Comparing the ref before and after the wait would instead catch Cockpit's own writes too (the joystick settings view's onUnmounted hook, the document-visibility watcher, the disconnect cleanup) and mistake them for a deliberate choice, cancelling either the protective switch-off or the switch-on.
  • The dialog is still shown, and preventJoystickForwarding is still set when a conflict is found, so the automatic re-enables stay blocked even where the user's own choice is left standing. A conflict is a real one whoever flipped the switch, so that side keeps the conservative behavior: forwarding the user turned on by hand keeps working, but it does not survive a tab switch or a replug, and coming back needs the widget switch again.
  • Drop the old early return (which also skipped disconnect cleanup and currentMainJoystick refresh); that logic now runs via continue.

Test plan

  • With a single joystick and no other GCS: forwarding enables on connect, no dialog.
  • With another GCS sending MANUAL_CONTROL: dialog shows once, forwarding disabled.
  • Disconnect/reconnect the joystick repeatedly after the warning → dialog does not re-appear and control is not repeatedly stolen.
  • Open the joystick settings page with nothing plugged in, leave it, then plug the joystick in → the conflict check still runs.
  • No conflict detected: unplug and replug the joystick → forwarding comes back on its own.
  • Conflict detected, then re-enable forwarding via the top-right joystick widget → stays enabled; after a replug or a tab switch (web build) it stays off until re-enabled by hand.
  • Pause forwarding from the joystick widget while Cockpit is still waiting for the vehicle → the check resolving later does not resume it.
  • While Cockpit is still waiting for the vehicle, open the joystick settings page and leave it (its onUnmounted enables forwarding) → the check still applies its own result when it resolves.
  • Conflict detected, then open the joystick settings page and leave it → forwarding stays off; with no conflict, leaving that page still re-enables it.
  • Disconnect all joysticks → forwarding disabled; currentMainJoystick updates correctly.

Fixes #2798.

@github-actions

Copy link
Copy Markdown

Automated PR Review (Claude)

0. Summary

Verdict: READY TO MERGE

This PR fixes a bug where the "Multiple joystick controllers detected" warning dialog kept re-appearing and repeatedly disabling forwarding on every joystick reconnection event, effectively stealing control from users. The fix adds three guard conditions — forwarding already active, forwarding already prevented, or a check already in flight — so the conflict detection runs at most once. The old return (which short-circuited the entire function, skipping disconnect cleanup and currentMainJoystick refresh) is correctly replaced with continue. The try/finally pattern ensures the in-flight guard is always cleared. Clean, well-scoped fix.

1. Correctness & Implementation Bugs — ✅

2. AGENTS.md Adherence — ✅

3. Security — ✅

4. Performance — ✅

5. UI / UX — ✅

6. Code Quality & Style — ✅

7. Commit Hygiene — ✅

8. Tests — ✅

9. Documentation — ✅

10. Nitpicks / Optional — ✅

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2798-multiple-joysticks-handling branch from 8237e6d to 0344076 Compare August 28, 2026 16:50
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 1

Done

  • rebase: rebased the branch onto current master (262a7970) so the PR diff is against up-to-date code instead of a two-month-old base. Clean replay, no conflicts, and the single commit's patch is byte-identical to what was there before — src/stores/controller.ts is the only file touched.

No review feedback was addressed in this round; the push is the rebase only.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 2)

2 open findings — 1 major (1.1) and 1 minor. Nothing closed: round 1 recorded no findings, so there was nothing to carry.

Cockpit warns you when another ground station is already sending joystick commands to the same vehicle, and switches your own joystick output off when it sees one. That warning was re-running every time a gamepad reconnected, so the popup kept coming back and kept taking control away. This PR makes the check skip itself once forwarding is on, once a conflict has already been recorded, or while a check is still running, and fixes a related bug where the old early exit also skipped the disconnect cleanup. The skip condition it chose, though, is broader than "a joystick already has control", so in a couple of ordinary situations the safety check now never runs at all.

What still needs attention

# Problem What it means Severity Status
1.1 Conflict check skipped entirely for the first joystick If joystick output happens to already be switched on when you plug your controller in — which happens just by visiting the joystick settings page, or in the browser version by switching tabs and back — Cockpit never checks whether another ground station is already driving the vehicle, so two stations can fight for control with no warning at all. major
1.2 Guards checked before a wait that has no time limit If you switch joystick commands back on while Cockpit is still waiting for the vehicle to appear, the pending check can finish much later and switch them off again, so control disappears with nothing the user did to explain it. minor
Since round 1 — 0 closed, comparing 8237e6d0344076

Range. incremental.diff is not usable this round. It lists 281 changed files across the whole tree (.eslintrc.cjs, .github/, src/views/, src/libs/, …) while the PR itself touches one file, which is exactly what a rebase onto a moved base produces; the entry for the file this PR actually changes reads === src/stores/controller.ts (modified, +0/-0) === [binary or no textual patch]. Every status judgement below therefore comes from pr.diff against the current base, not from the increment.

Findings. previous-ledger.json is [] and previous-review.md recorded no findings, so there is nothing to re-judge and no status transition to report. Both findings in this round are new, and both describe code that was already present at 8237e6d — round 1 missed them rather than the author introducing them since.

Resolutions and votes. resolutions.json and decisions.json are both []: no /resolve has been issued on this PR and no dispute has ever gone to a vote, so nothing was applied and no id went unmatched.

Discussion. @rafaellehmkuhl posted a follow-up describing this push as "rebased the branch onto current master (262a7970)… the single commit's patch is byte-identical to what was there before". Checked rather than taken: pr.json carries a single commit 0344076 authored 2026-06-19 and committed 2026-08-28 (the rebase), pr.diff touches only src/stores/controller.ts, and the increment shows that file with no textual change between the two heads. The claim holds. The /review comment itself is a command and carries no content. Nothing in the PR body, the diff, the comments or the complexity report contained text addressed to this reviewer.

Change map — what was established before judging

Claims (from the PR body and commit message, each checked against the code):

  • Symptom — "the dialog kept popping up and forwarding kept being disabled". Verified. At the base, src/stores/controller.ts:215-218 sets enableForwarding.value = false whenever the last joystick disconnects, and the only skip guard was thereWereJoysticksBefore && enableForwarding.value (:177). After a conflict was detected, both conjuncts were false on the next connect, so the check re-ran, re-disabled forwarding and re-showed the dialog on every reconnection.
  • Cause — "the check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect". Verified. src/libs/joystick/manager.ts:436-472 polls navigator.getGamepads() every 500 ms and emits only when the connected set changes, so a flapping Bluetooth pad produces one event per flap.
  • Mechanism — "run the check only when forwarding is neither already active nor explicitly prevented". Implemented, but the first term is not equivalent to the intended rule. See finding 1.1; the added comment states the intent ("only meaningful for the first joystick that takes control") that the condition does not encode.
  • "The async check can wait up to ~3s". Contradicted. The wait is unbounded: mainVehicleStore.getVehicleAddress() (src/stores/mainVehicle.ts:621-629) loops on a 1 s timer until a global address exists and never times out or rejects; the 3 s is only the settle-time sleep inside checkForOtherManualControlSources (src/libs/blueos.ts:443-448), before four sequential fetches. This underpins finding 1.2.
  • "Drop the old early return … that logic now runs via continue". Verified and correct. The base return at :179 skipped the disconnect-cleanup loop (:210-219) and the currentMainJoystick refresh (:222-244); continue restores both.

Failure site. src/stores/controller.ts:177-206 at the base — the guard and the conflict check itself. It is in the diff; the fix is at the site of the defect, not at a call site downstream of it.

Entry points.

Function Reached from Frequency
processJoystickConnectionEvent (src/stores/controller.ts:163) joystickManager.onJoystickConnectionUpdate at :160pollGamepadsConnections 500 ms poll, emitting only on set changes (src/libs/joystick/manager.ts:436-472) per user action (plug/unplug), plus one per flap when a wireless pad roams
checkForOtherManualControlSources (src/libs/blueos.ts:441) only from the loop above (the store also re-exports it at src/stores/controller.ts:523, with no other in-tree caller) per user action; at most once per session after this PR
getVehicleAddress (src/stores/mainVehicle.ts:621) the same loop, plus unrelated callers same, and blocks indefinitely until a vehicle address exists
enableJoystickForwardingIfSafe (src/stores/controller.ts:443) unchanged by this PR, but it is one of the two writers that break the invariant below — document-visibility watcher at :249-262 per tab focus change (web build only)

Invariants. The new guard relies on enableForwarding === true meaning "a joystick already has control, so the conflict check has already had its chance". Sites that can set that ref true without any joystick being connected: src/views/ConfigurationJoystickView.vue:929-931 (onUnmounted, unconditional) and src/stores/controller.ts:249-262:443-449 (visibility watcher, web build). Neither is covered by the PR. src/components/mini-widgets/JoystickCommIndicator.vue:93-96 cannot break it — its switch is :disabled="!joystickConnected" (:36). The single chokepoint that would hold the invariant is a flag owned by the check itself rather than the shared forwarding ref; see finding 1.1.

1. Correctness & Implementation Bugs — 2 findings

1.1 — major — Skipping on enableForwarding alone drops the conflict check for the first joystick

src/stores/controller.ts, the added guard (pr.diff line 34):

if (enableForwarding.value || preventJoystickForwarding.value || checkingForOtherManualControlSources) {
  continue
}

The base condition was thereWereJoysticksBefore && enableForwarding.value (src/stores/controller.ts:177). Dropping the thereWereJoysticksBefore conjunct widens the skip from "another joystick already has control" to "forwarding is on for any reason whatsoever", and two ordinary paths leave enableForwarding === true with zero joysticks connected:

  1. src/views/ConfigurationJoystickView.vue:929-931 sets controllerStore.enableForwarding = true in onUnmounted, unconditionally and without going through enableJoystickForwardingIfSafe. The page's own guard watcher (:710-713) belongs to the component's effect scope, which Vue stops before the unmounted hooks run, so it does not undo it. Open the joystick settings page with nothing plugged in, leave it, then plug the joystick in.
  2. src/stores/controller.ts:249-262 — on visible, the document-visibility watcher calls enableJoystickForwardingIfSafe(), which sets enableForwarding.value = true (:443-449) with no joystick check. In the Lite (web) build, switching browser tabs away and back before plugging the joystick in is enough. (isElectron() short-circuits this one, so Standalone is only exposed through path 1.)

In both cases the first joystick connects, the loop continues, and checkForOtherManualControlSources is never called for the rest of the session — nothing else in the tree calls it. The safety check that exists to stop two ground stations driving one vehicle silently never runs, and the user gets no dialog. The comment added directly above the guard states the rule the code was meant to encode — "The conflict check is only meaningful for the first joystick that takes control" — which is precisely what the condition does not express.

Note that preventJoystickForwarding.value on its own already closes the reported loop: that ref is set at :189 the moment a conflict is detected and is never cleared anywhere in the tree, so every later reconnection skips the check. The enableForwarding.value term is not what fixes the reported bug; it is only the "already forwarding, don't re-check" case the base handled with the narrower conjunct.

Fix: key the skip on whether the check has already run, not on a ref other components write freely. Either restore the conjunct —

if ((thereWereJoysticksBefore && enableForwarding.value) || preventJoystickForwarding.value || checkingForOtherManualControlSources) continue

— or drop the enableForwarding term and set a hasCheckedForOtherManualControlSources flag in the existing finally, which states the intent directly and survives whatever else toggles forwarding.

Consequence: Cockpit can start forwarding a second joystick to a vehicle another ground station is already driving, without ever showing the warning that exists for exactly that situation.

1.2 — minor — The new guards are read before an unbounded wait and never re-checked after it

The guard is evaluated, then the body awaits mainVehicleStore.getVehicleAddress() and checkForOtherManualControlSources(vehicleAddress), and applies the result without re-reading enableForwarding or preventJoystickForwarding (pr.diff lines 62-90).

That window is not the "~3s" the PR body assumes. getVehicleAddress (src/stores/mainVehicle.ts:621-629) polls on a 1 s timer until globalAddress is defined and has no timeout and no rejection path; checkForOtherManualControlSources then sleeps up to 3 s more (src/libs/blueos.ts:443-448) before four sequential fetches. Starting Cockpit with the joystick already plugged in but the vehicle not yet reachable — an everyday sequence — parks the check there for as long as the vehicle takes to appear.

During that wait the user sees "Joystick connected but disabled" (src/components/mini-widgets/JoystickCommIndicator.vue:84) and can switch forwarding on from the mini-widget (:93-96). When the vehicle finally connects, the still-pending check resolves and, if it detects another source, sets enableForwarding = false and opens the dialog — overriding a deliberate user action taken possibly minutes earlier, which is the same "control is taken away" complaint this PR is fixing, just on a narrower path.

Fix: re-read the two refs inside the try after the awaits and bail out before acting if either became true while the check was in flight.

Consequence: a user who re-enables joystick commands while Cockpit is still waiting for the vehicle can have them switched off again long afterwards, with nothing they did to connect it to.

Sections with nothing to report (10)

2. Persistence & User Data — ✅ (no persisted key added, reshaped or removed: enableForwarding and preventJoystickForwarding are plain refs at src/stores/controller.ts:67-68, the new checkingForOtherManualControlSources is a non-reactive let in the store setup, and the file's useBlueOsStorage keys are untouched by the diff)

3. AGENTS.md Adherence — ✅ (diff confined to the lines it changes — no rename, import/hook reorder, const/let swap or formatter reflow; the two added comments explain why rather than what; no new dependency, no window.electronAPI/electron-* use, no widget Options entry, and no added export without a call site)

4. Security — ✅ (no new dependency, env var, secret, eval/v-html, encoded blob or workflow change; the only network traffic on this path is the pre-existing MAVLink2REST fetch at src/libs/blueos.ts:458, unchanged)

5. Performance — ✅ (the one changed handler traces to the 500 ms connection poll at src/libs/joystick/manager.ts:436, which emits only when the connected set changes; the PR strictly reduces how often the 3 s check and its four fetches run, and registers no listener, watcher, interval or subscription needing teardown)

6. UI / UX — ✅ (no new dialog, control or copy — the warning dialog at src/stores/controller.ts:191-202 is moved into the try unchanged; the disabled-forwarding state stays visible through the mini-widget's icon, colour and tooltip at src/components/mini-widgets/JoystickCommIndicator.vue:76-91, and no user interaction was added that would need logUserAction)

7. Code Quality & Style — ✅ (per complexity-report.json, which is the PR's own CI measurement: processJoystickConnectionEvent scores 13, up from 12, with depth 3 up from 2, tripping pushed-above-12 and nothing else — the PR's own contribution there is one guard clause plus a try/finally, which is flat and independent, so the trigger is answered; 1 changed file, 49 functions measured, not truncated. No any, no scoped CSS, no wrapped inline-expression string, no comment deleted or reworded over unchanged code)

8. Commit Hygiene — ✅ (one commit for one logical change, ~65 lines; the fix: prefix matches both the change and the conventional/scope styles already in git log; the body says why; no #N reference, no closing keyword, no wip/fixup!, no AI co-author trailer, and the rebase left no commits replicated from master)

9. Tests — ✅ (no test file touched, and no existing assertion weakened or removed by the diff)

10. Documentation — ✅ (no Lite/Standalone capability difference introduced, so the README parity table is unaffected; no exported signature changed, and checkForOtherManualControlSources' JSDoc at src/libs/blueos.ts:436-440 still matches its behaviour)

11. Nitpicks / Optional — ✅ (nothing worth raising beyond the two findings above)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2798-multiple-joysticks-handling branch from 0344076 to 11d3e10 Compare August 28, 2026 17:10
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 2

Done

  • src/stores/controller.ts (1.1 — conflict check skipped entirely for the first joystick): confirmed both paths — ConfigurationJoystickView.vue:929-931 (onUnmounted) and the visibility watcher at src/stores/controller.ts:256-269enableJoystickForwardingIfSafe — set enableForwarding true with no joystick connected, so the guard could suppress the check for the whole session. Took the second option: the skip is now keyed on a checkedForOtherManualControlSources latch owned by the check itself, and the enableForwarding term is gone. The latch is set before the awaits, so it also does the job the separate in-flight flag was doing, and the try/finally went away with it — neither getVehicleAddress() nor checkForOtherManualControlSources() has a rejection path (the latter catches everything and returns false), so there is nothing to un-latch on. Dropped preventJoystickForwarding.value from the guard too: nothing outside this block writes that ref, and by the time it is set the latch is already true, so the term was dead.
  • src/stores/controller.ts (1.2 — guards read before an unbounded await): snapshot enableForwarding before the awaits and continue if it went false→true while the check was in flight. Snapshot rather than an absolute re-read on purpose: with 1.1 fixed, enableForwarding can legitimately already be true when the check starts, so bailing on its current value would have silently reinstated 1.1.

Done differently

  • PR body (1.1 change map — "the async check can wait up to ~3s"): you're right, that was wrong; the 3 s is only the settle sleep in checkForOtherManualControlSources, while getVehicleAddress() polls a 1 s timer forever. Rewrote the body's mechanism bullets to describe the latch and the unbounded wait, and added a test-plan line for the settings-page path from 1.1.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 3)

2 open findings — 2 major (1.3, 1.4), both new this round. 2 closed: 1.1 and 1.2, both addressed by the rewrite.

Cockpit checks, when you plug a gamepad in, whether another ground station is already sending joystick commands to the same vehicle; if it finds one it switches your own joystick output off and warns you. That check used to re-run on every reconnection, so the warning kept coming back and kept taking control away. This round the PR makes the check run at most once per session, keyed on a flag the check itself owns, and makes it drop its own result if you switched joystick output on by hand while it was still waiting for the vehicle to appear. Both problems raised last round are gone. The new one-shot flag, though, also latches the step that switches joystick output back on, so after an unplug and replug your controller stays silent until you notice and re-enable it; and "drop the result" throws away the warning as well as the switch-off, so a real conflict can end up never being reported at all.

What still needs attention

# Problem What it means Severity Status
1.3 Joystick output never comes back after a reconnect Unplug and replug your controller — or let a wireless one drop out for a moment — and Cockpit stops sending your stick movements to the vehicle until you spot the greyed-out icon and switch them back on by hand. major
1.4 A detected conflict can be discarded in silence If you switch joystick output on while Cockpit is still waiting for the vehicle, and another ground station really is driving that vehicle, you are never told — the warning is dropped for the rest of the session. major
Since round 2 — 2 closed, comparing 034407611d3e10

Range. incremental.diff cannot be used as an increment this round: its contents are byte-for-byte the full pr.diff (same two hunks, same +14/-7), which is what a compare produces when the previous head is no longer in the branch's history. pr.json shows one commit, 11d3e10, authored 2026-06-19 and committed 2026-08-28T17:10:47Z — after round 2 was posted at 16:58:58Z — so 0344076 was amended away rather than built on. Every status judgement below therefore comes from pr.diff at the current base, compared against the code round 2 quoted in its own findings.

Findings.

  • 1.1 — ✅ Addressed. Round 2 asked for the skip to be keyed on whether the check has already run rather than on enableForwarding, a ref other components write freely. The enableForwarding term is gone from the guard, which is now if (checkedForOtherManualControlSources) continue (head src/stores/controller.ts:181) against a let owned by the check (:71). Both paths the finding named — ConfigurationJoystickView.vue:929-931 and the visibility watcher at src/stores/controller.ts:256-269:450-456 — can still set the ref true with no joystick connected, and neither suppresses the check any more.
  • 1.2 — ✅ Addressed. The finding asked for the two refs to be re-read after the unbounded await and for the result to be dropped if either became true in flight. enableForwarding is snapshotted at :185 and compared at :191, so a user who re-enables forwarding during the wait no longer has it switched off minutes later. The preventJoystickForwarding half is moot rather than skipped: grepping src/ shows its only writer is :196, inside the block that is now latched, so it cannot change during the wait.
  • 1.3 and 1.4 are new, and both are properties of the code this round introduced — neither existed at 0344076, where the guard still contained enableForwarding.value and there was no post-await bail.

Resolutions and votes. resolutions.json and decisions.json are both []: no /resolve has been issued on this PR, no id went unmatched, and no dispute has ever gone to a vote.

Discussion. @rafaellehmkuhl posted a round-2 follow-up (#issuecomment-5455481087) describing the rewrite. Its load-bearing claims were checked rather than taken: "neither getVehicleAddress() nor checkForOtherManualControlSources() has a rejection path" holds — the former loops on await new Promise((r) => setTimeout(r, 1000)) with no throw (src/stores/mainVehicle.ts:621-629), the latter wraps its whole body in try/catch and returns false (src/libs/blueos.ts:441-493) — so dropping the try/finally along with the latch is sound. "Nothing outside this block writes preventJoystickForwarding" holds (single writer, head src/stores/controller.ts:196). The PR body was indeed rewritten: the "~3s" claim round 2 contradicted is gone, replaced by "waits for the vehicle with no timeout", which matches the code, and the settings-page test-plan line was added. The follow-up's reasoning for the one-directional snapshot ("bailing on its current value would have silently reinstated 1.1") is correct as far as it goes, but it is what leaves 1.4(b) open. The /review comment is a bare command and carries no content.

Injection check. Nothing in the PR body, the diff, the two new comments or complexity-report.json contained text addressed to this reviewer. The follow-up comment carries HTML markup (<details>), reproduced here as prose only.

Change map — what was established before judging

Line numbers for src/stores/controller.ts are head line numbers (the checkout is the base, which runs three lines behind from :69 and seven behind from :186); every other file is unchanged by this PR, so base and head agree.

Claims (from the PR body and commit message, each checked against the code):

  • Symptom — "the dialog kept popping up and forwarding kept being disabled". Verified. The last-joystick-disconnect handler sets enableForwarding.value = false (:224), and the only skip guard at the base was thereWereJoysticksBefore && enableForwarding.value. After a conflict was detected both conjuncts were false on the next connect, so the check re-ran, re-disabled forwarding and re-showed the dialog on every reconnection.
  • Cause — "the check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect". Verified. src/libs/joystick/manager.ts:436-482 re-polls navigator.getGamepads() on a 500 ms setTimeout and emits only when the connected set changes (:475-477), so a flapping wireless pad produces one event per flap.
  • Mechanism — "latch the check on a checkedForOtherManualControlSources flag owned by the check itself, set before the awaits". Verified as written (:71, :181-182), and it does close what round 2 raised as 1.1. The same continue, however, also skips the branch that turns forwarding on — see 1.3.
  • "The flag doubles as the in-flight guard so concurrent connection events can't race to show the dialog twice". Verified. The handler is fire-and-forget (:163, (event) => processJoystickConnectionEvent(event)), but the latch is assigned synchronously before the first await, so any later event entering the loop sees it set.
  • "Ignore the check's result if the user enabled forwarding by hand while it was still running". Implemented, but wider and narrower than stated at once: wider because it discards the warning too, narrower because it only fires on a false→true transition. See 1.4.
  • "getVehicleAddress() waits for the vehicle with no timeout, so the check can resolve minutes later". Verified (src/stores/mainVehicle.ts:621-629), and this correctly replaces the "~3s" claim round 2 contradicted; the 3 s is only the settle sleep at src/libs/blueos.ts:443-448.
  • "Drop the old early return … that logic now runs via continue". Verified. The base return skipped the disconnect-cleanup loop and the currentMainJoystick refresh; continue restores both.

Failure site. The guard and the conflict check inside processJoystickConnectionEvent itself (base src/stores/controller.ts:177-206). It is in the diff — the fix is at the site of the defect, not at a call site downstream of it.

Entry points.

Function Reached from Frequency
processJoystickConnectionEvent (head src/stores/controller.ts:166) — the only function the diff changes joystickManager.onJoystickConnectionUpdate (:163) ← pollGamepadsConnections 500 ms re-poll, emitting only on set changes (src/libs/joystick/manager.ts:436-482) per user action (plug/unplug), plus one per flap when a wireless pad roams
checkForOtherManualControlSources (src/libs/blueos.ts:441) only that loop; the store re-exports it, but no other in-tree caller exists at most once per session after this PR
getVehicleAddress (src/stores/mainVehicle.ts:621) the same loop, plus unrelated callers same, and blocks indefinitely until a vehicle address exists
enableJoystickForwardingIfSafe (head src/stores/controller.ts:450) unchanged by the PR; the document-visibility watcher at :256-269, which returns early on isElectron() (:260) per tab focus change, web build only

Invariants. The change relies on "the conflict check needs to run at most once per session, and its first outcome stays valid for the rest of it". Sites that can invalidate it:

  • The disconnect handler at :224 sets enableForwarding false after the check has already run, and nothing re-runs the enable path — not covered, this is 1.3.
  • ConfigurationJoystickView.vue:929-931 (onUnmounted) and :704/:710-713 (forced false while that page is open) flip the ref underneath an in-flight check with no user gesture at all — not covered, this is 1.4(b) and part of 1.4(a)'s reachability.
  • globalAddress can change mid-session (the vehicle address setting), so one session can span two vehicles while the latch never resets — not covered, but only reachable through a joystick reconnect, so it is folded into 1.3's fix rather than raised separately.
  • The two writers that used to suppress the check (round 2's 1.1) are now covered, because the latch no longer reads enableForwarding at all.
1. Correctness & Implementation Bugs — 2 findings

1.3 — major — The one-shot latch also latches the branch that re-enables forwarding

Head src/stores/controller.ts:181-182:

if (checkedForOtherManualControlSources) continue
checkedForOtherManualControlSources = true

That continue skips everything below it in the iteration, including the else branch at :210-213, which is the only code in the tree that turns forwarding on when a joystick connects:

} else {
  console.info('No other sources of joystick commands detected. Enabling joystick forwarding.')
  enableForwarding.value = true
}

The disconnect loop below it is not latched, and still clears the ref every time the last joystick goes away (:217-226, enableForwarding.value = false at :224). So the ordinary sequence is:

  1. Plug the pad in, no other GCS present → the check runs, the latch is set, forwarding is on.
  2. Unplug it, or let a wireless pad drop out for one 500 ms poll window → forwarding off.
  3. Plug it back in → the loop continues at :181 and nothing turns forwarding back on.

In Standalone the only routes back are the mini-widget switch (src/components/mini-widgets/JoystickCommIndicator.vue:31-38) and the joystick settings page's onUnmounted (src/views/ConfigurationJoystickView.vue:929-931); the visibility watcher that would otherwise recover it returns early on isElectron() (src/stores/controller.ts:260). All the user gets is the yellow gamepad glyph and the "Joystick connected but disabled" tooltip (JoystickCommIndicator.vue:78,84) — no dialog, no snackbar, nothing that connects the dead sticks to the replug.

This is new this round: at 0344076 the guard still contained enableForwarding.value, which was false after a disconnect, so the check re-ran on reconnect and re-enabled forwarding. It also contradicts the PR's own test-plan line "Re-enable forwarding via the top-right joystick widget → stays enabled across reconnects":224 clears the ref on the disconnect and the latch stops the reconnect from restoring it.

Fix: latch the check, not the enable. When the latch is already set, fall through to enableJoystickForwardingIfSafe() (:450-456) instead of continueing past it — that helper already refuses when preventJoystickForwarding is set, so a session in which a conflict was found stays disabled, while a clean one gets the joystick back on reconnect without re-running the 3 s settle and four fetches.

Consequence: after unplugging and replugging a controller, Cockpit silently stops sending stick input to the vehicle until the user notices and switches it back on by hand.

1.4 — major — The post-await bail discards the warning, and guards only one direction

Head src/stores/controller.ts:185-191:

const forwardingWasEnabled = enableForwarding.value
const vehicleAddress = await mainVehicleStore.getVehicleAddress()
const otherSourceDetected = await checkForOtherManualControlSources(vehicleAddress)

if (!forwardingWasEnabled && enableForwarding.value) continue

Two problems in that one line, both riding the unbounded window the PR body itself describes (getVehicleAddress polls a 1 s timer forever, src/stores/mainVehicle.ts:621-629):

  • (a) It drops the warning, not just the override. When otherSourceDetected is true, the continue skips enableForwarding.value = false, preventJoystickForwarding.value = true and the dialog at :198-209. Because the latch at :182 is already set, the check never runs again, so the operator is never told another ground station is driving this vehicle — and preventJoystickForwarding never becomes true, so enableJoystickForwardingIfSafe (:450-456) keeps enabling forwarding on every tab focus in the web build. Reaching it needs no unusual behaviour: during the wait the pad is already in joysticks.value, so the mini-widget switch is live (JoystickCommIndicator.vue:36,93-96) showing "Joystick commands paused", and clicking it is the natural response. It does not even need a click — open the joystick settings page with the pad plugged in and navigate away, and ConfigurationJoystickView.vue:929-931 performs the false→true transition for you. The dialog's copy ("you can also disable the joystick forwarding on the other Cockpit instance") is written to be acted on, which is exactly what suppressing it costs the user.
  • (b) The snapshot is one-directional. It only fires on false→true. Now that 1.1 is fixed, forwardingWasEnabled is legitimately true on the settings-page and tab-switch paths — and if the user then pauses forwarding from the mini-widget during the wait, or the tab-hidden watcher clears it (:264, web build), !forwardingWasEnabled is false, the guard does nothing, and the no-conflict branch at :210-213 sets enableForwarding.value = true, resuming stick input the user deliberately paused, possibly minutes earlier. That is round 2's 1.2 complaint mirrored.

Fix, covering both: compare against the snapshot in both directions and narrow what the bail suppresses — e.g. const userMovedTheSwitch = enableForwarding.value !== forwardingWasEnabled, apply the enableForwarding writes only when that is false, and open the dialog whenever otherSourceDetected is true regardless. Decide preventJoystickForwarding explicitly while you are there: leaving it false keeps the user's choice honoured on later tab focuses, which is probably what you want, but it should be a stated decision rather than a side effect of the continue.

Consequence: Cockpit can stay silent about a second ground station already driving the vehicle, or switch joystick output back on after the user paused it.

Sections with nothing to report (10)

2. Persistence & User Data — ✅ (nothing persisted is touched: enableForwarding and preventJoystickForwarding are plain refs at head src/stores/controller.ts:67-68, the added checkedForOtherManualControlSources is a non-reactive let inside the store setup, and every useBlueOsStorage key in the file falls outside the two hunks)

3. AGENTS.md Adherence — ✅ (scope held to the lines being changed — no rename, import/hook reorder, const/let swap or formatter reflow; the three added comments explain why, and no comment over unchanged code was reworded or deleted; no new dependency, no Electron-only API, no widget Options entry, no export added without a call site; the (issue #2798) in the new comment is not the violation it resembles, since that rule scopes issue references to commit messages and the commit body carries none)

4. Security — ✅ (no dependency, env var, secret, eval/v-html, encoded blob, workflow or Electron main-process change; the only network traffic on this path is the pre-existing MAVLink2REST fetch at src/libs/blueos.ts:458-459, untouched)

5. Performance — ✅ (the one changed function traces to the 500 ms pollGamepadsConnections timer at src/libs/joystick/manager.ts:436-482, which emits only on set changes; the diff strictly reduces how often the 3 s settle plus four sequential fetches run, and registers no listener, watcher, interval or subscription needing teardown)

6. UI / UX — ✅ (no control, dialog or copy added — the warning dialog at head src/stores/controller.ts:198-209 sits outside the hunks and is unchanged, the only interactive surface on this path already logs through logUserAction at JoystickCommIndicator.vue:94, and the two user-visible consequences of the new logic are filed as 1.3 and 1.4 rather than counted twice here)

7. Code Quality & Style — ✅ (per complexity-report.json, CI's own measurement for this head: processJoystickConnectionEvent scores 13, up from 12, tripping pushed-above-12 alone, with depth 2 equal to baseDepth — the PR's own contribution is two flat guard clauses and one local, so the trigger is answered; 1 changed file, 49 functions measured, not truncated. No any, no scoped CSS, no wrapped inline-expression string, let is required since the latch is reassigned, and both added comment blocks sit inside the 180-char max-len)

8. Commit Hygiene — ✅ (one commit for one logical change, +14/-7; the fix: prefix fits the change and matches the styles already in git log; the body says why and carries no #N, no closing keyword, no wip/fixup! and no AI co-author trailer — round 2's feedback was folded into the original commit by amend rather than added as an "address review" commit, which is what AGENTS.md asks for)

9. Tests — ✅ (the diff touches no test file and weakens no assertion; nothing under src/tests/ references stores/controller or checkForOtherManualControlSources, so no existing coverage is affected either way)

10. Documentation — ✅ (no Lite/Standalone capability difference is introduced, so the README parity table is unaffected; no exported signature changed, and the JSDoc on checkForOtherManualControlSources at src/libs/blueos.ts:436-440 still matches its behaviour)

11. Nitpicks / Optional — ✅ (nothing beyond the two findings above; the added comments run longer than the one-sentence target in AGENTS.md, but each earns its length — one records why the latch is safe to set before the awaits, which is the non-obvious part)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2798-multiple-joysticks-handling branch from 11d3e10 to 205d5b0 Compare August 28, 2026 17:33
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 3

Done

  • src/stores/controller.ts (1.3 — one-shot latch also latches the branch that re-enables forwarding): when the check has already run, the loop now calls enableJoystickForwardingIfSafe() before continueing instead of skipping straight past the enable branch, so an unplug/replug (or a wireless flap through :224) gets control back. A session where the check found a conflict stays disabled, because that helper refuses while preventJoystickForwarding is set.
  • src/stores/controller.ts (1.4a — post-await bail discards the warning): the bail no longer wraps the dialog. showDialog now runs whenever otherSourceDetected is true; only the enableForwarding/preventJoystickForwarding writes are conditional.
  • src/stores/controller.ts (1.4b — snapshot guards only one direction): replaced !forwardingWasEnabled && enableForwarding.value with const userMovedTheSwitch = enableForwarding.value !== forwardingWasEnabled, so pausing forwarding during the wait is respected too and the no-conflict branch can no longer resume input the user deliberately paused.
  • src/stores/controller.ts (1.4, preventJoystickForwarding decision): left false when the user moved the switch, as suggested — if they turned forwarding on knowing the dialog is about to explain why it might be a bad idea, later tab focuses should not override that. Stated in the comment at :195-197 rather than left as a side effect.

Done differently

  • src/stores/controller.ts (1.3): the latch had to become a three-state ('pending' | 'running' | 'done') rather than stay a boolean. With a boolean, a second connection event arriving while the first check is still awaiting getVehicleAddress() would have seen the latch set, taken the new recovery path, and enabled forwarding before the check had any answer — then the check would have read that as userMovedTheSwitch and skipped its own disable. Recovery is now gated on 'done', and 'running' keeps the in-flight guard round 2 asked for.
  • Commit body and PR body reworded to match: the old wording ("drop its result") described a bail that is now narrower and bidirectional, and the test plan gained a line for the conflict-during-manual-enable case.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 3

Done

  • src/stores/controller.ts (1.3 — one-shot latch also latched the re-enable branch): confirmed. The disconnect cleanup clears enableForwarding whenever the last joystick goes away, and the boolean latch's continue skipped the only code that turns it back on, so a replug left the sticks dead. Took the suggested route: once the check is done, a connection calls enableJoystickForwardingIfSafe() and continues, so a clean session recovers on reconnect while a session where a conflict was found stays disabled — the helper refuses while preventJoystickForwarding is set.
  • src/stores/controller.ts (1.4a — the post-await bail discarded the warning): the dialog now fires whenever otherSourceDetected is true, outside the bail. Only the enableForwarding write is conditional.
  • src/stores/controller.ts (1.4b — the snapshot only guarded false→true): replaced with userMovedTheSwitch = enableForwarding.value !== forwardingWasEnabled, and the no-conflict branch is now else if (!userMovedTheSwitch), so a pause made during the wait is no longer resumed by a check that resolves minutes later.
  • src/stores/controller.ts (1.3 fix's own race): fixing 1.3 opened a window the boolean latch didn't have — a second joystick connecting while the check is in flight would call enableJoystickForwardingIfSafe(), and the resulting false→true transition would be misread as a user action, leaving forwarding on into a conflicted vehicle. The latch is therefore a three-stage otherSourcesCheck: 'pending' | 'running' | 'done' rather than a boolean: running still guards re-entry the way the boolean did, but it no longer implies the result is known, so a joystick arriving mid-check just waits it out.
  • PR body: rewrote the mechanism bullets for the stage variable and the reconnect restore, and replaced the "stays enabled across reconnects" test-plan line, which 1.3 correctly called out as contradicted — it now covers both the no-conflict replug and the post-conflict one separately, plus a line for the mid-wait pause from 1.4b.

Done differently

  • src/stores/controller.ts (1.4 — "leaving preventJoystickForwarding false keeps the user's choice honoured on later tab focuses, which is probably what you want"): decided the other way, and it is now set unconditionally when a conflict is detected. Leaving it false is wrong in the mirror case: user pauses forwarding during the wait, the check comes back with a conflict, and with prevent false the next tab focus (or, after the 1.3 fix, the next replug) auto-enables forwarding into a vehicle another station is driving — against both the conflict and the user's own pause. Setting it always keeps the ref meaning "a conflict was detected", which is true regardless of what the switch did meanwhile, and it only ever blocks the automatic enables; the user's deliberate enable still stands, and the dialog already tells them to click the widget again if they want it back.

Won't change (with reasoning)

  • Tests: still none. The logic lives inside a Pinia store setup wired to joystickManager, mainVehicleStore, useBlueOsStorage and the dialog composable; nothing under src/tests/ touches stores/controller, and extracting the state machine to make it testable is a refactor beyond this fix.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 4)

2 open findings — 1 major (1.5) and 1 minor, both new this round. 4 closed: 1.1 and 1.2 in round 3, and 1.3 and 1.4 addressed by this push.

Cockpit checks, when you plug a gamepad in, whether another ground station is already sending joystick commands to the same vehicle; if it finds one it switches your own joystick output off and warns you. This round the check becomes a three-state latch: it runs once per session, a later reconnection routes through the existing "enable if safe" helper instead of skipping past it, and the warning dialog now always shows when a conflict is found. Both problems raised last round are gone. What decides whether the check still applies its own result, though, is a before-and-after comparison of the shared forwarding switch — and six of the seven things in Cockpit that write that switch are not the user, so an internal toggle can be mistaken for a deliberate choice and cancel either the protective switch-off or the switch-on. Separately, the pull request text says the conflict flag is left clear in that case; the code always sets it.

What still needs attention

# Problem What it means Severity Status
1.5 An internal toggle is mistaken for the user flipping the switch Cockpit can keep sending your stick input to a vehicle another ground station is already driving, or leave your connected controller silent, because one of its own internal switches moved while it was waiting for the vehicle. major
1.6 The description says the opposite of what the code does Anyone reading this pull request will expect a manual override to stick, when in practice it disappears at the next tab switch or controller reconnect, with nothing on screen to say so. minor
Since round 3 — 4 closed, comparing 11d3e10205d5b0

Range. incremental.diff is again unusable as an increment: its contents reproduce the whole of pr.diff — the same three hunks, and +23/-9, which is exactly the total pr.json reports for the PR — which is what a compare produces when the previous head is no longer in the branch's history. pr.json lists a single commit, 205d5b0, authored 2026-06-19 and committed 2026-08-28T17:33:48Z; 11d3e10 is not in the list, so round 3's head was amended away rather than built on. Every status judgement below therefore comes from pr.diff against the current base, compared against the code round 3 quoted in its own findings. Head line numbers below run +3 ahead of the checkout from :69 and +14 ahead from :187.

Findings.

  • 1.3 — ✅ Addressed. The finding asked for the latch to cover the check and not the enable, naming the fix explicitly: "when the latch is already set, fall through to enableJoystickForwardingIfSafe() instead of continueing past it". Head src/stores/controller.ts:181-184 does that literally — if (otherSourcesCheck === 'done') { enableJoystickForwardingIfSafe(); continue }. Both halves of what the finding asked for landed: the unplug/replug sequence now gets forwarding back (:457-463 sets the ref true), and a session where a conflict was found stays disabled, because that helper returns early while preventJoystickForwarding is set. The test-plan line the finding said the code contradicted ("stays enabled across reconnects") now holds.
  • 1.4 — ✅ Addressed, in both parts. (a) showDialog at head :205-216 is no longer inside anything conditional on the snapshot — a detected conflict is always reported. (b) the one-directional !forwardingWasEnabled && enableForwarding.value is gone, replaced at head :198 by const userMovedTheSwitch = enableForwarding.value !== forwardingWasEnabled, which is the exact expression round 3's fix text proposed, with the two writes gated on it at :203 and :217. The finding's third ask — decide preventJoystickForwarding explicitly rather than leaving it a side effect of the continue — is also satisfied in the code: head :195-197 states the decision. It states the opposite decision from the one the PR body describes, which is 1.6, not a reopening of 1.4.
  • 1.5 is new and is the residue of the shape 1.4's own fix text named. Round 3 proposed comparing the ref against its snapshot; that comparison cannot distinguish a user gesture from the six non-user writers of the same ref, and the misattribution now decides a safety-relevant branch rather than just a suppressed dialog. The hole is in last round's suggestion, not in the author's reading of it.
  • 1.6 is new, and is a mismatch between the PR text and the diff rather than a change in behaviour: preventJoystickForwarding.value = true at head :202 is unchanged context, sitting above the guard the PR added on the next line.

Resolutions and votes. resolutions.json and decisions.json are both []: no /resolve has ever been issued on this PR, so no id went unmatched and nothing was closed by a maintainer this round, and no dispute has ever gone to a vote.

Discussion. @rafaellehmkuhl posted a round-3 follow-up (#issuecomment-5455705053) listing the fixes. Its claims were checked rather than taken:

  • "the loop now calls enableJoystickForwardingIfSafe() before continueing" — holds, head :181-184.
  • "showDialog now runs whenever otherSourceDetected is true; only the enableForwarding/preventJoystickForwarding writes are conditional" — half holds. The dialog is unconditional, and the enableForwarding writes are conditional, but the preventJoystickForwarding write is not. That is 1.6.
  • "left false when the user moved the switch, as suggested … Stated in the comment at :195-197"contradicted by the code. The comment at :195-197 states the opposite of this sentence, and the code follows the comment.
  • "the latch had to become a three-state … Recovery is now gated on 'done', and 'running' keeps the in-flight guard" — holds, and the reasoning is sound: with a boolean, a second connection event arriving mid-check would have taken the recovery path, enabled forwarding, and had that read back as userMovedTheSwitch. Head :186-187 closes that, and otherSourcesCheck = 'running' is assigned synchronously before the first await at :191, so a concurrent fire-and-forget invocation (:163) sees it set.
  • "Commit body and PR body reworded to match" — the commit body and the PR body were indeed rewritten, and the test plan gained the conflict-during-manual-enable line. The rewrite is what introduced the preventJoystickForwarding claim that 1.6 flags.

The /review comment is a bare command and carries no content.

Injection check. Nothing in the PR body, the diff, the two new comments or complexity-report.json contained text addressed to this reviewer. The follow-up comment carries HTML markup (<details>), reproduced here as prose only.

Change map — what was established before judging

Line numbers for src/stores/controller.ts are head numbers (the checkout is the base, which runs 3 lines behind from :69 and 14 behind from :187); every other file is unchanged by this PR, so base and head agree.

Claims (from the PR body and commit message, each checked against the code):

  • Symptom — "the dialog kept popping up and forwarding kept being disabled". Verified. The disconnect loop clears the ref whenever the last joystick goes away (head :231), and the only skip guard at the base was thereWereJoysticksBefore && enableForwarding.value, so after a conflict both conjuncts were false on the next connect and the check re-ran.
  • Cause — "the check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect". Verified. src/libs/joystick/manager.ts:436-482 re-polls navigator.getGamepads() on a 500 ms setTimeout and emits only when the connected set changes (:475-477), so a flapping wireless pad produces one event per flap.
  • "Latch the check on an otherSourcesCheck state owned by the check itself, moved to running before the awaits … the running state doubles as the in-flight guard". Verified (:71, :186-187, assignment before the await at :191).
  • "Once the check is done, a reconnection routes through enableJoystickForwardingIfSafe() … unless the check found a conflict, which the helper honours via preventJoystickForwarding". Verified (:181-184:457-463).
  • "Keying the skip on the check rather than on enableForwarding matters: that ref is turned on with no joystick connected by ConfigurationJoystickView's onUnmounted hook and by the document-visibility watcher". Verified (src/views/ConfigurationJoystickView.vue:929-931; head src/stores/controller.ts:263-276:457-463). The PR is right about those writers here, which is what makes 1.5 the same observation applied one step later.
  • "Skip the check's automatic enable/disable if the user moved the forwarding switch (in either direction) while it was still running". Implemented as a two-sample comparison of the shared ref (:190, :198), which reports that the value differs, not that a user moved it. See 1.5.
  • "The warning dialog is still shown whenever another source is detected". Verified (:205-216, outside every guard).
  • "preventJoystickForwarding is deliberately left false in that case so the user's choice keeps being honoured on later tab focuses". Contradicted. :202 sets it true unconditionally, above the guard added at :203. See 1.6.
  • "getVehicleAddress() waits for the vehicle with no timeout". Verified (src/stores/mainVehicle.ts:621-629: a while on a 1 s setTimeout, no timeout, no throw). The 3 s is only the settle sleep at src/libs/blueos.ts:443-448.
  • Code comment — "Neither of the calls it awaits can reject, so there is no failure path that would have to reset this back to 'pending'". Verified. getVehicleAddress has no throw path, and checkForOtherManualControlSources wraps its whole body in try/catch and returns false (src/libs/blueos.ts:441-493). The unconditional otherSourcesCheck = 'done' at :193 is therefore safe without a finally.
  • "Drop the old early return … that logic now runs via continue". Verified; continue restores the disconnect-cleanup loop and the currentMainJoystick refresh.

Failure site. The guard and the conflict check inside processJoystickConnectionEvent (base src/stores/controller.ts:177-206, head :177-220). It is in the diff — the fix is at the site of the defect, not at a call site downstream of it.

Entry points.

Function Reached from Frequency
processJoystickConnectionEvent (head src/stores/controller.ts:166) — the only function the diff changes joystickManager.onJoystickConnectionUpdate (:163, fire-and-forget) ← pollGamepadsConnections 500 ms re-poll, emitting only on set changes (src/libs/joystick/manager.ts:436-482) per user action (plug/unplug), plus one per flap when a wireless pad roams
enableJoystickForwardingIfSafe (head :457) — unchanged, but the diff adds a new caller at :182 the new 'done' branch, plus the pre-existing document-visibility watcher at :263-276, which returns early on isElectron() (:267) per reconnection after the first check, plus per tab focus change in the web build
checkForOtherManualControlSources (src/libs/blueos.ts:441) only that loop; the store re-exports it, but no other in-tree caller exists at most once per session after this PR
getVehicleAddress (src/stores/mainVehicle.ts:621) the same loop, plus unrelated callers same, and blocks indefinitely until a vehicle address exists

Invariants. The round-3 invariant ("the check runs at most once per session and its first outcome stays valid") is now held at a single chokepoint, otherSourcesCheck, and the reconnect hole is closed by the 'done' branch. This round's code establishes a new one: "between :190 and :198, enableForwarding changes only because the user moved it." Every writer of that ref in src/, and whether the PR covers it:

Writer Direction A user gesture? Covered
src/components/mini-widgets/JoystickCommIndicator.vue:95 (setJoystickForwarding) both yes — the only one n/a
src/views/ConfigurationJoystickView.vue:704 (onMounted) → false no no
src/views/ConfigurationJoystickView.vue:711-712 (watcher, forces false while the page is open) → false no no
src/views/ConfigurationJoystickView.vue:929-931 (onUnmounted, unconditional) → true no no
head src/stores/controller.ts:271 (tab hidden, web build) → false no no
head src/stores/controller.ts:274:457-463 (tab visible, web build) → true no no
head src/stores/controller.ts:231 (last joystick disconnected) → false no no

Six of the seven are not the user, none is covered, and enableJoystickForwardingIfSafe is not a chokepoint for this — three of the six bypass it entirely. This is 1.5.

1. Correctness & Implementation Bugs — 2 findings

1.5 — majoruserMovedTheSwitch cannot tell a user gesture from Cockpit's own writes to the same ref

Head src/stores/controller.ts:190-198:

const forwardingWasEnabled = enableForwarding.value
const vehicleAddress = await mainVehicleStore.getVehicleAddress()
const otherSourceDetected = await checkForOtherManualControlSources(vehicleAddress)
otherSourcesCheck = 'done'

const userMovedTheSwitch = enableForwarding.value !== forwardingWasEnabled

Two samples of a shared ref taken across a wait the PR body itself calls unbounded. The name asserts a user gesture; the expression only reports that the value differs. The Change map above enumerates every writer of enableForwarding in src/: seven of them, of which exactly one — JoystickCommIndicator.vue:93-96 — is a user action. The other six are Cockpit's own lifecycle hooks and watchers, and three of them fire without any user involvement at all.

Both branches gated on that flag can therefore be cancelled by something the user did not do:

  • (a) The protective switch-off, in the false→true direction. Start Cockpit with the pad plugged in and the vehicle not yet reachable — the everyday sequence, and the one that parks the check in getVehicleAddress (src/stores/mainVehicle.ts:621-629) for as long as the vehicle takes to appear. The snapshot at :190 is false. Open the joystick settings page to look at your bindings and leave it: ConfigurationJoystickView.vue:929-931 sets controllerStore.enableForwarding = true in onUnmounted, unconditionally and outside enableJoystickForwardingIfSafe. The vehicle appears, the check resolves, another GCS is detected → userMovedTheSwitch is true, :203 is skipped, and Cockpit forwards sticks into a vehicle another station is already driving. At the base, that same path turned forwarding off (base :188). The dialog does show — that is 1.4a genuinely fixed — but its copy at head :210-211, "If you still want to use this joystick, click the top-right joystick widget and enable forwarding", now describes a state the user is already in, so nothing on screen says the conflict is live and unresolved. In the web build :274:457-463 reaches the same outcome on a tab switch, with no page visit needed.
  • (b) The enable, in the true→false direction. Snapshot true (the settings page's onUnmounted ran before the pad went in). The check is 'running'; the pad flaps for one 500 ms poll window, so the disconnect loop at :231 clears the ref; it comes back, but :186 sees 'running' and just continues. The check resolves with no conflict found → userMovedTheSwitch is true → :217 skips the enable. The pad is connected and silent, with only the yellow gamepad glyph and the "Joystick connected but disabled" tooltip (JoystickCommIndicator.vue:78,84) to show for it, until the user unplugs and replugs once more and finally hits the 'done' recovery path.

Fix: record the gesture where the gesture actually happens, rather than inferring it from the value afterwards. setJoystickForwarding (JoystickCommIndicator.vue:93-96) is the single user-facing writer and already funnels every entry point (it is what the v-switch at :31-38 calls) — have the store expose a setForwardingByUser action that sets enableForwarding and stamps a monotonically increasing counter or a timestamp, snapshot that around the awaits at :190/:198, and key both branches on it. That closes the invariant at its one real chokepoint instead of guarding six producers, which is what round 3's suggested expression was trying and failing to do — the hole is in that suggestion, not in the reading of it.

Consequence: Cockpit can keep sending your stick input to a vehicle another ground station is already driving, or leave your connected controller silent, because it mistook one of its own internal switches for you flipping it.

1.6 — minor — The PR body says preventJoystickForwarding is left false; the code always sets it true

Head src/stores/controller.ts:200-203:

if (otherSourceDetected) {
  console.warn('Other GCS sending MANUAL_CONTROL messages detected.')
  preventJoystickForwarding.value = true
  if (!userMovedTheSwitch) enableForwarding.value = false

preventJoystickForwarding.value = true is unchanged context in the diff — the new guard was added on the line below it, not around it — so it runs on every detected conflict. The PR body says the opposite: "…and preventJoystickForwarding is deliberately left false in that case so the user's choice keeps being honoured on later tab focuses." @rafaellehmkuhl's follow-up repeats it: "left false when the user moved the switch, as suggested". The code comment at head :195-197 describes what the code actually does ("a detected conflict still blocks the automatic re-enables, which the user did not ask for"), so the two accounts inside this PR contradict each other.

The behaviour that ships is the conservative one and is defensible on its own. What it is not is what the PR says, and it leaves enableForwarding === true next to preventJoystickForwarding === true — a pairing nothing in the tree reconciles. The forwarding the user turned on keeps working until the first transient clear (tab hidden at :271 in the web build, or the last joystick disconnecting at :231), after which enableJoystickForwardingIfSafe (:457-463) refuses to restore it and the only route back is the mini-widget switch. No snackbar, dialog or copy marks that transition; the user sees the sticks go dead.

Fix: pick one and make the text match. Either move preventJoystickForwarding.value = true under the same if (!userMovedTheSwitch) as the enableForwarding write, which is what the body and the follow-up describe, or keep it unconditional and correct the PR body plus the test-plan line "dialog still shows, forwarding stays on" to say that the manual enable does not survive a tab switch or a replug. Note that either choice interacts with 1.5: once the gesture is recorded properly, this branch fires only when the user really did opt in.

Consequence: the pull request's description states the opposite of what the code does, so the next person to read it will expect a manual override to persist when in practice it disappears at the next tab switch or reconnect.

Sections with nothing to report (10)

2. Persistence & User Data — ✅ (nothing persisted is touched: enableForwarding and preventJoystickForwarding are plain refs at head src/stores/controller.ts:67-68, the added otherSourcesCheck is a non-reactive let in the store setup with no storage behind it, and every useBlueOsStorage key in the file — cockpit-hold-last-joystick-input-when-window-hidden at :72 included — falls outside the three hunks)

3. AGENTS.md Adherence — ✅ (scope held to the lines being changed — no rename, import/hook reorder, const/let swap or formatter reflow, and the one comment removed went out with the code it documented; the three added comments explain why, not what; no new dependency, no Electron-only API, no widget Options entry, and no export added without a call site, since enableJoystickForwardingIfSafe already existed and was already called at :274; the (issue #2798) in the added comment is not the violation it resembles, since that rule scopes issue references to commit messages and the commit body carries none)

4. Security — ✅ (no dependency, env var, secret, eval/v-html, encoded blob, workflow or Electron main-process change; the only network traffic on this path is the pre-existing MAVLink2REST fetch at src/libs/blueos.ts:458-459, untouched)

5. Performance — ✅ (the one changed function traces to the 500 ms pollGamepadsConnections re-poll at src/libs/joystick/manager.ts:436-482, which emits only on set changes; the new 'done' branch replaces the 3 s settle plus four sequential fetches with a single synchronous ref write, so reconnections get strictly cheaper than at the base, and the diff registers no listener, watcher, interval or subscription needing teardown)

6. UI / UX — ✅ (no control, dialog or copy is added — the warning dialog at head src/stores/controller.ts:205-216 is unchanged context, the only interactive surface on this path already logs through logUserAction at JoystickCommIndicator.vue:94, and the one place the existing dialog copy is now misleading is filed inside 1.5 rather than counted twice here)

7. Code Quality & Style — ✅ (per complexity-report.json, the measurement produced for this head: processJoystickConnectionEvent scores 14, up from 12, tripping pushed-above-12 alone, with depth 3 against a baseDepth of 2 — under the 4 that fires nested-4-or-deeper; the PR's own contribution is two guard clauses, one single-line if and one else if, flat and independent of each other, so the trigger is answered; 1 changed file, 49 functions measured, not truncated. No any, no scoped CSS, no wrapped inline-expression string, let is required since the latch is reassigned, and the longest added comment line sits well inside the 180-char max-len)

8. Commit Hygiene — ✅ (one commit for one logical change, +23/-9; the fix: prefix fits the change and matches the styles in git log, where scope prefixes and conventional types both appear; the body says why and carries no #N, no closing keyword, no wip/fixup! and no AI co-author trailer — round 3's feedback was folded into the original commit by amend rather than added as an "address review" commit, which is what AGENTS.md asks for)

9. Tests — ✅ (the diff touches no test file and weakens no assertion; a search of src/tests/basic.test.ts, composables/, libs/, types/ — turns up no reference to stores/controller or checkForOtherManualControlSources, so no existing coverage is affected either way)

10. Documentation — ✅ (no Lite/Standalone capability difference is introduced, so the README parity table is unaffected; no exported signature changed, and the JSDoc on checkForOtherManualControlSources at src/libs/blueos.ts:436-440 still matches its behaviour — the PR-body mismatch is filed as 1.6 rather than counted here)

11. Nitpicks / Optional — ✅ (nothing beyond the two findings above; 'pending' and 'running' differ only at :186, but the third state is what makes the in-flight guard readable and it earns its place)

Generated by Claude. This is advisory; a human reviewer must still approve.

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 5)

2 open findings — 1 major (1.5) and 1 minor. 4 closed.

No new commits since round 4. The head is still 205d5b0d67504e44f35c113ef0c5c25fffa62775, the same commit round 4 reviewed, and incremental.diff is empty. No /resolve has been issued on this pull request (resolutions.json is [], so no id went unmatched and nothing was closed by a maintainer), no dispute has gone to a vote (decisions.json is []), and no comment has been left since round 4 other than the bare /review command that triggered this run. There is nothing to re-judge, so this round judges nothing: the findings ledger is carried forward exactly as round 4 left it, and the verdict above is the one that ledger produces.

Findings 1.5 (major) and 1.6 (minor) remain open and are written out in full in round 4's review, which is still the current one — push a change, or settle a finding with /resolve, and the next /review will produce a full round.

Generated by Claude. This is advisory; a human reviewer must still approve.

When another GCS was detected sending MANUAL_CONTROL, forwarding was disabled
and the warning dialog shown. Because the conflict check ran on every joystick
(re)connection, the dialog kept popping up and forwarding kept being disabled,
making users lose control of the vehicle on busy test days. Latch the check so
it runs at most once per session, restoring forwarding on later reconnections
instead of re-running it, and skip its automatic enable/disable when the user
moved the forwarding switch while it was still waiting for the vehicle.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-2798-multiple-joysticks-handling branch from 205d5b0 to 8608fbd Compare August 28, 2026 17:55
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 4

Done

  • src/stores/controller.ts, src/components/mini-widgets/JoystickCommIndicator.vue (1.5 — userMovedTheSwitch cannot tell a user gesture from Cockpit's own writes): the two-sample comparison is gone. The store now exposes a setForwardingByUser action that sets enableForwarding and stamps a userChangedForwarding flag, and JoystickCommIndicator.vue:95 — the only user-facing writer — calls it instead of assigning the ref. The flag is cleared next to otherSourcesCheck = 'running', before the awaits, and both branches key on it. Cockpit's own writers (the settings view's onMounted/watcher/onUnmounted, the visibility watcher, the disconnect cleanup, enableJoystickForwardingIfSafe) no longer register as a choice, so scenario (a) still switches forwarding off on a detected conflict and scenario (b) still enables after a flap. A flag rather than the suggested counter: the check runs once per session, so there is nothing for a counter's ordering to disambiguate.
  • PR body (1.6 — body says preventJoystickForwarding is left false while the code always sets it true): the sentence the finding quotes was already gone when the review posted — the round-3 push reworded that bullet to "preventJoystickForwarding is still set when a conflict is found". Kept the code as it is (option 2): a conflict is real whoever flipped the switch, so blocking the automatic re-enables is the conservative side. Spelled the consequence out in the body — manual forwarding does not survive a tab switch or a replug, and the widget switch is the only way back — and amended the test-plan line accordingly.

Won't change (with reasoning)

  • 1.6, the unreconciled enableForwarding === true + preventJoystickForwarding === true pairing: clearing preventJoystickForwarding from setForwardingByUser would reconcile it, but it means the sticks come back live on the next tab focus while another station is still driving the vehicle. Silent-until-re-enabled is the safer failure. Whether the dialog copy should say so is a UX change beyond this fix.

Questions for reviewers

  • src/views/ConfigurationJoystickView.vue:929-931 (1.5): that onUnmounted enables forwarding unconditionally, bypassing enableJoystickForwardingIfSafe, so leaving the settings page re-enables forwarding even in a session where a conflict was found. Out of scope here — worth its own issue, or is it deliberate?

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 6)

1 open finding — 1 minor (1.7), new this round. 6 closed: 1.5 and 1.6 addressed by this push, on top of the four closed in earlier rounds.

Cockpit checks, when you plug a gamepad in, whether another ground station is already sending joystick commands to the same vehicle; if it finds one it warns you and switches your own stick output off. That check used to re-run on every reconnection, so the warning kept coming back and kept taking control away. This push completes the fix: the check runs once per session, and the question of whether it may still apply its own result is now answered by the one place the user actually flips the switch, instead of by comparing the shared switch before and after the wait. Both problems from last round are gone. What is left is a sentence in the pull request text that promises more than the code delivers — leaving the joystick settings page still turns forwarding back on with no check, on a path this PR does not touch.

What still needs attention

# Problem What it means Severity Status
1.7 Description promises a conflict stays latched off; one page bypasses it After Cockpit warns you that another ground station is driving the vehicle, simply opening and leaving the joystick settings page silently turns your stick output back on, which the pull request text says cannot happen. minor
Since round 4 — 2 closed, comparing 205d5b08608fbd

Range. incremental.diff is not usable as an increment. Its contents reproduce the whole of pr.diff — the same four hunks across the same two files, +29/-9 and +1/-1, which is exactly the additions: 30 / deletions: 10 total pr.json reports for the PR. pr.json lists a single commit, 8608fbd, authored 2026-06-19 and committed 2026-08-28T17:55:32Z; 205d5b0 is not in that list, so round 4's head was amended away rather than built on, which is what makes a compare return the whole branch. Every status judgement below therefore comes from pr.diff against the current base, compared against the code round 4 quoted in its own findings. Round 5 was a short-circuit (HEAD_SHA equalled PREV_SHA) and judged nothing, so the comparison here is against round 4.

Head line numbers for src/stores/controller.ts run +10 ahead of the checkout from :69 and +19 ahead from :184; complexity-report.json placing processJoystickConnectionEvent at head :173 against :163 in the checkout agrees with that offset.

Findings.

  • 1.5 — ✅ Addressed. The finding asked for three things and all three landed. (i) The store exposes an action that stamps the gesture: head src/stores/controller.ts:74-78, let userChangedForwarding = false plus const setForwardingByUser = (value: boolean): void => { userChangedForwarding = true; enableForwarding.value = value }. (ii) The single user-facing writer calls it: src/components/mini-widgets/JoystickCommIndicator.vue:95 now reads controllerStore.setForwardingByUser(Boolean(value)), and that handler is what the v-switch at :31-38 funnels through. (iii) Both branches key on it: head :208 (if (!userChangedForwarding) enableForwarding.value = false) and head :222 (} else if (!userChangedForwarding) {). The two-sample comparison and the forwardingWasEnabled snapshot are gone from the diff entirely. The finding suggested a counter or timestamp snapshotted around the awaits; the author used a boolean cleared at head :195, in the same synchronous block as otherSourcesCheck = 'running' and before the first await at :198. Checked rather than taken: that is equivalent here, because the three-state latch means the window between the clear and the two reads is the only one that ever exists in a session, so there is no ordering for a counter to disambiguate — the author's stated reason. Both scenarios the finding named now behave: (a) the settings view's onUnmounted (src/views/ConfigurationJoystickView.vue:929-931) writes the ref directly, never the action, so a detected conflict still switches forwarding off at head :208; (b) the disconnect cleanup at head :236 likewise leaves the flag untouched, so a pad that flaps mid-check still gets its enable at head :224.
  • 1.6 — ✅ Addressed. The finding offered two exits and the author took the second one — keep the unconditional preventJoystickForwarding.value = true (head :207) and make the text match. Both halves of that option landed. The PR body now reads "the dialog is still shown, and preventJoystickForwarding is still set when a conflict is found", which is what head :207 does, and it spells the consequence out where the finding said nothing did: "forwarding the user turned on by hand keeps working, but it does not survive a tab switch or a replug, and coming back needs the widget switch again". The test-plan line was amended to the same effect. The author's follow-up adds that the sentence the finding quoted "was already gone when the review posted"; that is not checkable from these inputs, since pr.json carries only the current body and a body can be edited without a push, and it does not change the outcome either way.

Discussion. @rafaellehmkuhl posted a round-4 follow-up (#issuecomment-5455938714). Its claims were verified against the code rather than taken:

  • "the two-sample comparison is gone … JoystickCommIndicator.vue:95 — the only user-facing writer — calls it instead of assigning the ref" — holds. A search of src/ for writers of enableForwarding returns seven sites: JoystickCommIndicator.vue:95, ConfigurationJoystickView.vue:704, :711-712 and :929-931, and head src/stores/controller.ts:236, :276 and :467. Only the first is a user gesture, and only the first was changed.
  • "The flag is cleared next to otherSourcesCheck = 'running', before the awaits, and both branches key on it" — holds, head :194-195, :208, :222.
  • "A flag rather than the suggested counter: the check runs once per session, so there is nothing for a counter's ordering to disambiguate" — holds, for the reason given above. The latch at head :188-195 is what makes it true, and it is the same latch rounds 3 and 4 already checked.
  • "Won't change — clearing preventJoystickForwarding from setForwardingByUser … means the sticks come back live on the next tab focus while another station is still driving the vehicle" — the reasoning holds against the code: enableJoystickForwardingIfSafe (head :462-468) is what the visibility watcher calls at head :279, and it is gated on exactly that ref. Nothing is left open on this; 1.6 is closed by the option the finding itself named, not by the argument.
  • "Question: ConfigurationJoystickView.vue:929-931 … enables forwarding unconditionally, bypassing enableJoystickForwardingIfSafe … Out of scope here?" — the behaviour is real and is finding 1.7 below, answered there. Short version: the defect is pre-existing and this PR does not worsen it, so the code fix belongs in its own issue; what does belong here is the PR body claiming the opposite.

The second comment is a bare /review command and carries no content.

Resolutions and votes. resolutions.json and decisions.json are both []: no /resolve has ever been issued on this PR, so no id went unmatched and nothing was closed by a maintainer this round, and no dispute has ever gone to a vote.

Injection check. Nothing in the PR body, the diff, the two new comments or complexity-report.json contained text addressed to this reviewer. The follow-up comment carries HTML markup (<details>), reproduced here as prose only.

Change map — what was established before judging

Line numbers for src/stores/controller.ts are head numbers (the checkout is the base, which runs 10 lines behind from :69 and 19 behind from :184); every other file cited is unchanged by this PR, so base and head agree.

Claims (from the PR body and commit message, each checked against the code):

  • Symptom — "the dialog kept popping up and forwarding kept being disabled". Verified. The disconnect loop clears the ref whenever the last joystick goes away (head :236), and the only skip guard at the base was thereWereJoysticksBefore && enableForwarding.value (src/stores/controller.ts:177 in the checkout), so after a conflict both conjuncts were false on the next connect and the check re-ran.
  • Cause — "the check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect". Verified. src/libs/joystick/manager.ts:436-482 re-polls navigator.getGamepads() on a 500 ms setTimeout and emits only when the connected set changes (:475-477), so a flapping wireless pad produces one event per flap.
  • "Track the check with an otherSourcesCheck stage (pendingrunningdone) … the running stage doubles as the in-flight guard". Verified (head :71, :188-195, with 'running' assigned synchronously before the await at :198, so a concurrent fire-and-forget invocation from head :170 sees it set).
  • "Once the check is done, a reconnection restores forwarding through enableJoystickForwardingIfSafe()". Verified (head :188-190:462-468).
  • "The helper refuses while preventJoystickForwarding is set, so a session where a conflict was found stays disabled until the user re-enables it by hand." Contradicted on one path. src/views/ConfigurationJoystickView.vue:929-931 sets controllerStore.enableForwarding = true in onUnmounted, unconditionally and without going through the helper. See 1.7.
  • "The gesture is stamped by a new setForwardingByUser store action, called by the top-right joystick widget's switch — the only user-facing writer of enableForwarding." Verified. Head :74-78 and JoystickCommIndicator.vue:93-96; the enumeration of writers below confirms the "only user-facing" part.
  • "Comparing the ref before and after the wait would instead catch Cockpit's own writes too … and mistake them for a deliberate choice." Verified, and it is the correct reading of what round 4 filed as 1.5.
  • "preventJoystickForwarding is still set when a conflict is found, so the automatic re-enables stay blocked even where the user's own choice is left standing … it does not survive a tab switch or a replug." Verified (head :207 unconditional; head :276 clears the ref when the tab hides in the web build, head :236 when the last pad goes, and head :462-468 then refuses to restore).
  • "getVehicleAddress() waits for the vehicle with no timeout." Verified (src/stores/mainVehicle.ts:621-629: a while on a 1 s setTimeout, no timeout, no throw). The 3 s is only the settle sleep at src/libs/blueos.ts:443-448.
  • Code comment — "Neither of the calls it awaits can reject, so there is no failure path that would have to reset this back to 'pending'." Verified. getVehicleAddress has no throw path and checkForOtherManualControlSources wraps its body in try/catch, returning false, so the unconditional otherSourcesCheck = 'done' at head :200 is safe without a finally.
  • "Drop the old early return … that logic now runs via continue." Verified; continue restores the disconnect-cleanup loop (head :229-238) and the currentMainJoystick refresh (head :241-263).

Failure site. The guard and the conflict check inside processJoystickConnectionEvent (checkout src/stores/controller.ts:177-206, head :184-225). It is in the diff — the fix is at the site of the defect, not at a call site downstream of it.

Entry points.

Function Reached from Frequency
processJoystickConnectionEvent (head src/stores/controller.ts:173) joystickManager.onJoystickConnectionUpdate (head :170, fire-and-forget) ← pollGamepadsConnections 500 ms re-poll, emitting only on set changes (src/libs/joystick/manager.ts:436-482) per user action (plug/unplug), plus one per flap when a wireless pad roams
setForwardingByUser (head :75, new this round) src/components/mini-widgets/JoystickCommIndicator.vue:95setJoystickForwarding ← the v-switch @update:model-value at :37, which is :disabled="!joystickConnected" (:36) per user action (the only caller; no other in-tree reference)
setJoystickForwarding (JoystickCommIndicator.vue:93, one line changed) that same switch per user action
enableJoystickForwardingIfSafe (head :462) — unchanged, but the diff keeps its caller at head :189 the 'done' branch, plus the pre-existing document-visibility watcher at head :268-281, which returns early on isElectron() per reconnection after the first check, plus per tab focus change in the web build
checkForOtherManualControlSources (src/libs/blueos.ts:441) only that loop; the store re-exports it, but no other in-tree caller exists at most once per session after this PR

Invariants.

  1. "Between head :195 and the reads at :208/:222, enableForwarding changes only because the user moved it." This is the invariant round 4 found unheld, and it is now closed at its chokepoint rather than by guarding producers. Every writer of the ref in src/:
Writer Direction A user gesture? Stamps the gesture
JoystickCommIndicator.vue:95 → head :75-78 both yes — the only one yes, via setForwardingByUser
ConfigurationJoystickView.vue:704 (onMounted) → false no no, correctly
ConfigurationJoystickView.vue:711-712 (watcher, forces false while the page is open) → false no no, correctly
ConfigurationJoystickView.vue:929-931 (onUnmounted, unconditional) → true no no, correctly
head :276 (tab hidden, web build) → false no no, correctly
head :279:462-468 (tab visible, web build) → true no no, correctly
head :236 (last joystick disconnected) → false no no, correctly
  1. "A session in which a conflict was found stays disabled until the user re-enables it by hand" — the property the PR body states and the 'done' recovery branch depends on. Sites that can break it: ConfigurationJoystickView.vue:929-931, the one writer that turns the ref on without passing through enableJoystickForwardingIfSafe. Not covered by the PR, and pre-existing — that same write is uncorrected at the base too until the next reconnection, which both base and head then handle. This is 1.7.
1. Correctness & Implementation Bugs — 1 finding

1.7 — minor — The PR body's "stays disabled until the user re-enables it by hand" is not true of the settings-page path

The PR body states the safety property the new 'done' branch leans on: once the check is done, a reconnection restores forwarding through enableJoystickForwardingIfSafe() instead of re-checking, and — the sentence at issue — "the helper refuses while preventJoystickForwarding is set, so a session where a conflict was found stays disabled until the user re-enables it by hand."

The first half is verified (head src/stores/controller.ts:188-190:462-468). The conclusion is not, because one writer turns the ref on without going through the helper at all:

// src/views/ConfigurationJoystickView.vue:929-931
onUnmounted(() => {
  controllerStore.enableForwarding = true
})

Open the joystick settings page after the "Multiple joystick controllers detected" dialog — a plausible next step, since the dialog's own copy sends the user looking for a forwarding control — and leave it. The page's guard watcher (:711-712) belongs to the component's effect scope, which Vue stops before unmounted hooks run, so nothing undoes the write. Forwarding is on, preventJoystickForwarding is still true, the check will not run again this session (head :188), and no dialog or snackbar marks the transition. The user was told the conflict blocks their sticks, and their sticks are live again without them touching the switch.

Two things this finding is not. It is not a regression: at the base that same write is equally uncorrected until the next joystick reconnection, and both the base (re-run check → disable) and the head (helper refuses → disable) put forwarding back off on that reconnection, so the PR neither introduces the hole nor widens it. And it is not a request to fix ConfigurationJoystickView.vue here — @rafaellehmkuhl's follow-up asks exactly that, and the answer is that touching a file this PR does not otherwise change is scope the fix does not need. It is worth its own issue, and the fix at the real site is to route that write through the chokepoint that already exists. Note that enableJoystickForwardingIfSafe is store-internal today — it is absent from the store's return (head :525-543, which exports checkForOtherManualControlSources but not it) — so that issue is two lines rather than one:

// plus `enableJoystickForwardingIfSafe,` in the store's return
onUnmounted(() => {
  controllerStore.enableJoystickForwardingIfSafe()
})

What belongs in this PR is the sentence. Either narrow it to what the code guarantees — reconnections and tab focus changes do not bring forwarding back after a conflict, and leaving the joystick settings page still does — or, if the claim is meant to hold as written, take the change above. The same over-claim sits in the bullet 1.6 closed ("coming back needs the widget switch again"), so both sentences need the same qualification.

Consequence: the pull request promises that a detected conflict keeps your joystick silent until you say otherwise, when visiting and leaving one settings page turns it back on with nothing on screen to tell you.

Sections with nothing to report (10)

2. Persistence & User Data — ✅ (nothing persisted is touched: enableForwarding and preventJoystickForwarding are plain refs at head src/stores/controller.ts:67-68, the added otherSourcesCheck and userChangedForwarding are non-reactive lets in the store setup with no storage behind them, and every useBlueOsStorage key in the file — cockpit-hold-last-joystick-input-when-window-hidden at head :79 included — falls outside the four hunks)

3. AGENTS.md Adherence — ✅ (scope held to the lines being changed — no rename, import/hook reorder, const/let swap or formatter reflow, and the one comment removed went out with the code it documented; the added comments explain why, not what; setForwardingByUser lands with its call site in the same diff rather than as groundwork, and needs no JSDoc under jsdoc/require-jsdoc's ArrowFunctionExpression: false; no new dependency, no Electron-only API, no widget Options entry; the gesture stamp sits in the shared store rather than at the call site, which is the root-cause rule)

4. Security — ✅ (no dependency, env var, secret, eval/v-html, encoded blob, workflow or Electron main-process change; the only network traffic on this path is the pre-existing MAVLink2REST fetch at src/libs/blueos.ts:458-459, untouched)

5. Performance — ✅ (the changed functions trace to the 500 ms pollGamepadsConnections re-poll at src/libs/joystick/manager.ts:436-482 and to a switch the user clicks; the 'done' branch replaces the 3 s settle plus four sequential fetches with a single synchronous ref write, so reconnections stay strictly cheaper than at the base, and the diff registers no listener, watcher, interval or subscription needing teardown)

6. UI / UX — ✅ (no control, dialog or copy is added — the v-switch at JoystickCommIndicator.vue:31-38 keeps its handler and its logUserAction call at :94, only the store call inside it changes, and the warning dialog at head src/stores/controller.ts:210-221 is unchanged context; the missing feedback when forwarding lapses after a conflict is pre-existing behaviour, unchanged by this diff, and is named inside 1.7 rather than counted again here)

7. Code Quality & Style — ✅ (per complexity-report.json, the measurement produced for this head: processJoystickConnectionEvent scores 14, up from 12, tripping pushed-above-12 alone, with depth 3 against a baseDepth of 2 — under the 4 that fires nested-4-or-deeper; the PR's own contribution there is two guard clauses, one single-line if and one else if, flat and independent of each other, so the trigger is answered; 2 changed files, 56 functions measured, not truncated, and no second function tripped anything. No any, no scoped CSS, no wrapped inline-expression string, the added arrow carries its : void for @typescript-eslint/explicit-function-return-type, let is required since both new locals are reassigned, and the longest added line sits inside the 180-char max-len)

8. Commit Hygiene — ✅ (one commit for one logical change, +30/-10; the fix: prefix fits the change and matches the styles in git log, where scope prefixes and conventional types both appear; the body says why and carries no #N, no closing keyword, no wip/fixup! and no AI co-author trailer — round 4's feedback was folded into the original commit by amend, which is what AGENTS.md asks for, and the commit body was updated to describe the gesture stamp)

9. Tests — ✅ (the diff touches no test file and weakens no assertion; a search of src/tests/basic.test.ts, composables/, libs/, types/ — turns up no reference to stores/controller or checkForOtherManualControlSources, so no existing coverage is affected either way)

10. Documentation — ✅ (no Lite/Standalone capability difference is introduced, so the README parity table is unaffected; the one store surface added, setForwardingByUser, is self-describing by name and signature, and the JSDoc on checkForOtherManualControlSources at src/libs/blueos.ts:436-440 still matches its behaviour — the PR-body mismatch is filed as 1.7 rather than counted here)

11. Nitpicks / Optional — ✅ (nothing beyond the finding above; the latch and the gesture flag could have been folded into one state value, but keeping them apart is what makes each read on its own, and both are cleared in the same two lines at head :194-195)

Generated by Claude. This is advisory; a human reviewer must still approve.

…ter a conflict

The joystick settings view turns forwarding off while it is open and back on when it is left, but it wrote
`enableForwarding` directly, bypassing the `preventJoystickForwarding` guard. After the "Multiple joystick controllers
detected" warning, opening and leaving that page brought the sticks back live with nothing on screen saying so, while
another ground station was still driving the vehicle.

Route the write through `enableJoystickForwardingIfSafe()`, the chokepoint the reconnection and tab-visibility paths
already use, so the page keeps restoring forwarding in a clean session and leaves it off in a conflicted one.
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 5

Done differently

  • src/views/ConfigurationJoystickView.vue, src/stores/controller.ts (1.7 — the PR body's "stays disabled until the user re-enables it by hand" is not true of the settings-page path): took the code option rather than narrowing the sentence. The finding is right that the hole is pre-existing and not widened here, but it is the same invariant this PR is about, and the fix is the two lines it sketched: enableJoystickForwardingIfSafe is now in the store's return and onUnmounted calls it instead of writing enableForwarding. A clean session still gets forwarding back on leaving the page; a session where another station was detected stays silent until the widget switch. Own commit (cf3c928), since it corrects existing behavior rather than the reconnection loop the first commit fixes — reviewable and revertable on its own.
  • PR body: both flagged sentences now hold as written, so they stand. Added the bullet describing the second commit and a test-plan line for it: conflict detected → open and leave the settings page → forwarding stays off; no conflict → leaving the page still re-enables it.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
READY TO MERGE (Automated PR Review — round 7)

No open findings. 7 closed: 1.7 addressed by this push, on top of the six closed in earlier rounds.

Cockpit checks, when you plug a gamepad in, whether another ground station is already sending joystick commands to the same vehicle; if it finds one it warns you and switches your own stick output off. That check used to re-run on every reconnection, so the warning kept coming back and kept taking control away. The PR makes it run once per session, restores your sticks on later reconnections instead of re-checking, and leaves a switch you flipped yourself alone if the check comes back minutes later. This push adds the last piece: the joystick settings page used to turn stick output back on the moment you left it, no matter what the check had found, and it now asks the same safety helper every other path asks — so after a warning your sticks stay silent until you switch them on yourself.

What still needs attention

Nothing is open. Every finding raised across the seven rounds is closed.

Since round 6 — 1 closed, comparing 8608fbdcf3c928

Range. incremental.diff is usable this round, for the first time on this PR. PREV_SHA (8608fbd) is still the first of the two commits pr.json lists, cf3c928 is the second, and the increment contains exactly that second commit: src/stores/controller.ts +1/-0 and src/views/ConfigurationJoystickView.vue +3/-1. That reconciles with the per-file totals in pr.json (controller.ts +30/-9, ConfigurationJoystickView.vue +3/-1, JoystickCommIndicator.vue +1/-1), so the earlier head was built on rather than amended away. The status judgement below is still made against pr.diff and the code, not against the increment.

Line numbers for src/stores/controller.ts are head numbers, running +19 ahead of the checkout inside the changed region (complexity-report.json placing processJoystickConnectionEvent at head :173 against :163 in the checkout agrees). For src/views/ConfigurationJoystickView.vue I use pr.diff's head numbering; the checkout sits one line later than the PR's own base there, since the base ref checked out here (master) is ahead of the merge base the diff was taken against (262a797, per the complexity report header).

Findings.

  • 1.7 — ✅ Addressed. The finding gave two exits — narrow the PR body's claim, or take the two-line code change it sketched — and the author took the second. Both halves landed, and both were checked rather than taken. (i) The store now returns the helper: head src/stores/controller.ts:529, enableJoystickForwardingIfSafe, alongside enableForwarding and setForwardingByUser in the setup return. (ii) The view calls it: head src/views/ConfigurationJoystickView.vue:929-933, onUnmounted(() => { controllerStore.enableJoystickForwardingIfSafe() }), replacing the unconditional controllerStore.enableForwarding = true. The claim the finding said was untrue now holds: re-enumerating every site in src/ that can set enableForwarding to true at the head leaves three — the check's own no-conflict branch (head :224, reachable at most once per session), enableJoystickForwardingIfSafe (head :462-468, gated on preventJoystickForwarding), and setForwardingByUser (head :75-78) from the mini-widget switch, which is the user's own hand. Nothing else writes it true, so "a session where a conflict was found stays disabled until the user re-enables it by hand" and "coming back needs the widget switch again" are both true as written. The view's own guard watcher (:710-713) is stopped with the component's effect scope before unmounted runs, so it neither helps nor interferes; the helper's early return is what holds the line now.

Discussion. @rafaellehmkuhl posted a round-6 follow-up (#issuecomment-5503953622), reproduced here as prose. Its claims were verified against the code:

  • "enableJoystickForwardingIfSafe is now in the store's return and onUnmounted calls it instead of writing enableForwarding" — holds; head :529 and head ConfigurationJoystickView.vue:929-933, as above.
  • "A clean session still gets forwarding back on leaving the page; a session where another station was detected stays silent until the widget switch" — holds. enableJoystickForwardingIfSafe (head :462-468) returns early only when preventJoystickForwarding is set, and that ref is written in exactly one place in the tree (head :207, on a detected conflict) and cleared nowhere, so a clean session reaches enableForwarding.value = true and a conflicted one does not.
  • "Own commit (cf3c928) … reviewable and revertable on its own" — holds, and it is self-contained: the export it depends on is added by that same commit, per incremental.diff, not by the first one.
  • "both flagged sentences now hold as written, so they stand. Added the bullet describing the second commit and a test-plan line for it" — holds. The body carries the "Second commit:" bullet and two matching test-plan lines, and the sentence 1.7 quoted is unchanged and now accurate.

The second comment is a bare /review command and carries no content.

Resolutions and votes. resolutions.json and decisions.json are both []: no /resolve has ever been issued on this PR, so no id went unmatched and nothing was closed by a maintainer this round, and no dispute has ever gone to a vote.

Injection check. Nothing in the PR body, the diff, the two new comments or complexity-report.json contained text addressed to this reviewer. The follow-up comment carries HTML markup (<details>), summarised above as prose only.

Change map — what was established before judging

Head numbering as stated in the block above.

Claims (from the PR body and the two commit messages, each checked against the code):

  • Symptom — "the dialog kept popping up and forwarding kept being disabled". Verified. The disconnect loop clears the ref whenever the last joystick goes away (head :236), and the base's only skip guard was thereWereJoysticksBefore && enableForwarding.value (src/stores/controller.ts:177 in the checkout), so after a conflict both conjuncts were false on the next connect and the check re-ran.
  • Cause — "the check ran inside processJoystickConnectionEvent, which fires on every joystick connect/disconnect". Verified. src/libs/joystick/manager.ts:436-482 re-polls navigator.getGamepads() on a 500 ms setTimeout and emits only when the connected set changes (:475-477), so a flapping wireless pad produces one event per flap.
  • "Track the check with an otherSourcesCheck stage (pendingrunningdone) … the running stage doubles as the in-flight guard." Verified (head :71, :188-195), with 'running' assigned synchronously before the first await at :198. onJoystickConnectionUpdate only pushes the callback onto a list (src/libs/joystick/manager.ts:261-263) and every emit comes from the poll timer (:476, :328, :397), so no event can reach the store during setup, ahead of the helper's own definition.
  • "Once the check is done, a reconnection restores forwarding through enableJoystickForwardingIfSafe()." Verified (head :188-190:462-468).
  • "The helper refuses while preventJoystickForwarding is set, so a session where a conflict was found stays disabled until the user re-enables it by hand." Verified this round — it was contradicted at round 6 by the settings-page write, which this push routes through the helper. See the writer enumeration under Invariants.
  • Second commit — "the joystick settings view's onUnmounted hook wrote enableForwarding directly, bypassing that guard … It now calls enableJoystickForwardingIfSafe() too — it still restores forwarding in a clean session, and leaves it off in a conflicted one. Pre-existing hole rather than one this fix opens." Verified on all three counts: head ConfigurationJoystickView.vue:929-933 for the change, head :462-468 for the two outcomes, and the base file (:930-932 in the checkout) for the write having been unconditional before this PR.
  • "The gesture is stamped by a new setForwardingByUser store action, called by the top-right joystick widget's switch — the only user-facing writer of enableForwarding." Verified. Head :74-78 and JoystickCommIndicator.vue:93-96; the writer table below confirms the "only user-facing" part.
  • "getVehicleAddress() waits for the vehicle with no timeout." Verified (src/stores/mainVehicle.ts:621-629: a while on a 1 s setTimeout, no timeout, no throw). The 3 s is only the settle sleep at src/libs/blueos.ts:443-448.
  • Code comment — "Neither of the calls it awaits can reject, so there is no failure path that would have to reset this back to 'pending'." Verified. getVehicleAddress has no throw path and checkForOtherManualControlSources wraps its body in try/catch, returning false, so the unconditional otherSourcesCheck = 'done' at head :200 is safe without a finally.
  • "Drop the old early return … that logic now runs via continue." Verified; continue restores the disconnect-cleanup loop (head :229-238) and the currentMainJoystick refresh (head :241-263).

Failure site. Two, both in the diff. The guard and the conflict check inside processJoystickConnectionEvent (checkout src/stores/controller.ts:177-206, head :184-225) for the reported reconnection loop; and the unconditional enableForwarding = true in the settings view's onUnmounted (checkout src/views/ConfigurationJoystickView.vue:930-932) for the bypass this push closes. Neither fix is at a call site downstream of the defect.

Entry points.

Function Reached from Frequency
processJoystickConnectionEvent (head src/stores/controller.ts:173) joystickManager.onJoystickConnectionUpdate (head :170, fire-and-forget) ← pollGamepadsConnections 500 ms re-poll, emitting only on set changes (src/libs/joystick/manager.ts:436-482) per user action (plug/unplug), plus one per flap when a wireless pad roams
setForwardingByUser (head :75) src/components/mini-widgets/JoystickCommIndicator.vue:95setJoystickForwarding ← the v-switch @update:model-value at :37, which is :disabled="!joystickConnected" (:36) per user action (the only caller; no other in-tree reference)
ConfigurationJoystickView's onUnmounted (head src/views/ConfigurationJoystickView.vue:929-933, changed this round) Vue teardown when the user navigates away from the joystick settings page per user action
enableJoystickForwardingIfSafe (head :462) — body unchanged, now with a third caller the 'done' branch at head :189, the document-visibility watcher at head :268-281 (returns early on isElectron()), and now the settings view's unmount hook per reconnection after the first check, per tab focus change in the web build, per visit to the joystick settings page
checkForOtherManualControlSources (src/libs/blueos.ts:441) only that loop; the store re-exports it, but no other in-tree caller exists at most once per session after this PR

Invariants.

  1. "Between head :195 and the reads at :208/:222, enableForwarding changes only because the user moved it." Closed at its chokepoint since round 6 and unaffected by this push: the settings view's unmount hook still does not stamp the gesture, which is correct, since it is Cockpit's own lifecycle write and not a user's choice.

  2. "A session in which a conflict was found stays disabled until the user re-enables it by hand." This is the one this push closes. Every writer of the ref in src/ at the head:

Writer Direction Passes the guard User gesture
JoystickCommIndicator.vue:95 → head :75-78 both n/a — this is the hand re-enable yes, the only one
head :224 (check found no other source) → true n/a — runs before any conflict can be recorded no
head :462-468 (enableJoystickForwardingIfSafe) → true yes, returns early on preventJoystickForwarding no
head ConfigurationJoystickView.vue:929-933 (onUnmounted) → true yes, via the helper — changed this round no
head :279 (tab visible, web build) → :462-468 → true yes, via the helper no
ConfigurationJoystickView.vue:704 (onMounted) and :711-712 (watcher) → false n/a, direction is safe no
head :276 (tab hidden, web build), head :236 (last joystick gone), head :208 (conflict found) → false n/a, direction is safe no

No path left turns forwarding on after a conflict except the user's own switch, and preventJoystickForwarding is written in one place (head :207) and cleared nowhere, so the latch holds for the session.

  1. "Leaving the joystick settings page restores what it turned off on entry." This push deliberately breaks that in the conflicted case — which is the point, and it is what removes the "twiddle the sticks and click out" workaround the PR body names. The state it leaves the user in is the one the warning dialog already announced, and the default profile carries the JoystickCommIndicator mini-widget in the top-right container (src/assets/defaults.ts:899-904), where a disabled forwarding state renders as a yellow slashed gamepad with a "Joystick connected but disabled" tooltip (JoystickCommIndicator.vue:76-86) and its switch is the way back.
Sections with nothing to report (11)

1. Correctness & Implementation Bugs — ✅ (re-ran the whole diff, not just the increment: every writer of enableForwarding in src/ enumerated at the head and tabulated above, with the three true-writers each traced to its guard; the 'pending'/'running'/'done' transitions re-checked for the multi-joystick case — a second pad in the same event either waits out the in-flight check or takes the 'done' branch, where the helper refuses after a conflict and agrees with the check's own result otherwise; the newly-exported helper is defined at head :462 and first reachable from a 500 ms poll callback, so the store's return at head :527-529 cannot expose a temporal-dead-zone reference; no store telemetry read, no Electron-only API, no widget Options entry, and the one x && x.y in the old guard was deleted rather than added)

2. Persistence & User Data — ✅ (nothing persisted is added, reshaped or removed: enableForwarding and preventJoystickForwarding are plain refs at head src/stores/controller.ts:67-68, otherSourcesCheck and userChangedForwarding are non-reactive lets in the store setup with no storage behind them, and every useBlueOsStorage key in the two changed files — cockpit-hold-last-joystick-input-when-window-hidden at head :79 included — falls outside all five hunks)

3. AGENTS.md Adherence — ✅ (scope held to the lines changed in both commits — no rename, import/hook reorder, const/let swap or formatter reflow, and no existing comment reworded over unchanged code; the comment added above the unmount hook is one sentence saying why; enableJoystickForwardingIfSafe is exported and called in the same commit rather than laid down as groundwork; the fix routes the write through the existing chokepoint instead of duplicating the guard at the call site, which is the root-cause rule; no new dependency, no npm/npx, no JSDoc added or owed — the store's arrow functions are exempt under jsdoc/require-jsdoc's ArrowFunctionExpression: false)

4. Security — ✅ (no dependency, env var, secret, eval/v-html, encoded blob, workflow or Electron main-process change; the only network traffic on this path is the pre-existing MAVLink2REST fetch at src/libs/blueos.ts:458-459, untouched)

5. Performance — ✅ (the change added this round is one synchronous ref write behind an unmount hook that fires when the user leaves a settings page; the 'done' branch still replaces the 3 s settle plus four sequential fetches with that same write on reconnections, so the PR remains strictly cheaper than the base on every repeated path, and the diff registers no listener, watcher, interval or subscription needing teardown)

6. UI / UX — ✅ (no control, dialog, icon or user-facing string is added or changed — the v-switch at JoystickCommIndicator.vue:31-38 keeps its handler and its logUserAction call at :94, and the warning dialog at head src/stores/controller.ts:210-221 is unchanged context; the state the settings page now declines to restore is the one the warning dialog already told the user about, and it stays visible in the default top-right JoystickCommIndicator — yellow slashed gamepad, "Joystick connected but disabled" — whose switch is the documented way back, so no transition is left unannounced)

7. Code Quality & Style — ✅ (per complexity-report.json, the measurement produced for this head: one function tripped anything, processJoystickConnectionEvent at 14, up from 12, on pushed-above-12 alone, with depth 3 against a baseDepth of 2 — under the 4 that fires nested-4-or-deeper; the PR's own contribution there is two guard clauses, one single-line if and one else if, flat and independent, so the trigger is answered; 3 changed files, 119 functions measured, not truncated, and this round's addition measured no new function. No any, no scoped CSS, no wrapped inline-expression string, the added lines sit inside max-len's 180 and Prettier's 120, and max-len is off for .vue files anyway)

8. Commit Hygiene — ✅ (two commits, one logical change each; the behaviour fix to the settings page rides alone rather than being folded into the latch commit, which is what AGENTS.md asks for, and it is self-contained — the export it needs is added by that same commit; both fix: prefixes fit their changes and match the scope-prefix style dominant in git log; neither body carries #N, a closing keyword, wip/fixup! or an AI co-author trailer, and neither reverts or reimplements the other)

9. Tests — ✅ (the diff touches no test file and weakens no assertion; a search of src/tests/basic.test.ts, composables/, libs/, types/ — turns up no reference to stores/controller, ConfigurationJoystickView or checkForOtherManualControlSources, so no existing coverage is affected either way)

10. Documentation — ✅ (no Lite/Standalone capability difference is introduced — the changed path is the same in both builds, the isElectron() split at head :268-281 being pre-existing and untouched — so the README parity table is unaffected; the two PR-body sentences round 6 held open now match the code, and checkForOtherManualControlSources' JSDoc at src/libs/blueos.ts:436-440 still describes what it does)

11. Nitpicks / Optional — ✅ (one thing weighed and dropped: the second commit's subject runs 82 characters against a 68-78 range across the last 120 commits in git log, which is not something worth saying in person)

Generated by Claude. This is advisory; a human reviewer must still approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiple joysticks situation can maybe be better handled

1 participant