diff --git a/packages/web/src/components/ui/__tests__/ContextMenuCheckbox.test.tsx b/packages/web/src/components/ui/__tests__/ContextMenuCheckbox.test.tsx
new file mode 100644
index 00000000..1fe57a02
--- /dev/null
+++ b/packages/web/src/components/ui/__tests__/ContextMenuCheckbox.test.tsx
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useContextMenuStore } from '../../../stores/contextMenuStore';
+import { ContextMenuRenderer } from '../ContextMenuRenderer';
+
+// Minimal mock for uiStore (ContextMenuRenderer reads isMobile)
+vi.mock('../../../stores/uiStore', () => ({
+ useUIStore: (selector: (s: { isMobile: boolean }) => unknown) =>
+ selector({ isMobile: false }),
+}));
+
+beforeEach(() => {
+ // Reset the context menu store to closed state between tests to prevent
+ // state leaking from one test into the next.
+ useContextMenuStore.getState().close();
+});
+
+describe('ContextMenu checkbox reactivity', () => {
+ it('updates checkbox indicator when store value changes on click', async () => {
+ const user = userEvent.setup();
+
+ // External state the checkbox subscribes to
+ let currentValue = false;
+ const listeners = new Set<() => void>();
+
+ const subscribe = (cb: () => void) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ };
+ const getChecked = () => currentValue;
+ const onChange = (checked: boolean) => {
+ currentValue = checked;
+ listeners.forEach((cb) => cb());
+ };
+
+ // Open context menu with a reactive checkbox
+ useContextMenuStore.getState().open({ x: 100, y: 100 }, [
+ {
+ key: 'test-checkbox',
+ type: 'checkbox',
+ label: 'Test Mute',
+ subscribe,
+ getChecked,
+ onChange,
+ },
+ ]);
+
+ render();
+
+ const button = screen.getByText('Test Mute').closest('button')!;
+ expect(button).toBeTruthy();
+
+ // Checkbox should start unchecked — the indicator's inner SVG checkmark should not be present
+ const getCheckmark = () => button.querySelector('svg[viewBox="0 0 24 24"]');
+ expect(getCheckmark()).toBeNull();
+
+ // Click to mute
+ await user.click(button);
+
+ // Checkbox should now be checked
+ expect(currentValue).toBe(true);
+ expect(getCheckmark()).not.toBeNull();
+
+ // Click again to unmute
+ await user.click(button);
+
+ // Checkbox should be unchecked again
+ expect(currentValue).toBe(false);
+ expect(getCheckmark()).toBeNull();
+ });
+
+ it('does not close the menu after toggling a checkbox', async () => {
+ const user = userEvent.setup();
+
+ let currentValue = false;
+ const listeners = new Set<() => void>();
+
+ useContextMenuStore.getState().open({ x: 100, y: 100 }, [
+ {
+ key: 'test-checkbox',
+ type: 'checkbox',
+ label: 'Stay Open',
+ subscribe: (cb: () => void) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ getChecked: () => currentValue,
+ onChange: (checked: boolean) => {
+ currentValue = checked;
+ listeners.forEach((cb) => cb());
+ },
+ },
+ ]);
+
+ render();
+
+ await user.click(screen.getByText('Stay Open'));
+
+ // Menu should still be open (item still visible)
+ expect(screen.getByText('Stay Open')).toBeTruthy();
+ expect(useContextMenuStore.getState().menu).not.toBeNull();
+ });
+
+ it('updates checkbox when external code changes the store (not via click)', async () => {
+ // External state the checkbox subscribes to
+ let currentValue = false;
+ const listeners = new Set<() => void>();
+
+ const subscribe = (cb: () => void) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ };
+ const getChecked = () => currentValue;
+
+ useContextMenuStore.getState().open({ x: 100, y: 100 }, [
+ {
+ key: 'test-checkbox',
+ type: 'checkbox',
+ label: 'External Update',
+ subscribe,
+ getChecked,
+ onChange: () => {},
+ },
+ ]);
+
+ render();
+
+ const button = screen.getByText('External Update').closest('button')!;
+ const getCheckmark = () => button.querySelector('svg[viewBox="0 0 24 24"]');
+
+ // Starts unchecked
+ expect(getCheckmark()).toBeNull();
+
+ // Simulate external store change (e.g., another component or WebSocket event)
+ act(() => {
+ currentValue = true;
+ listeners.forEach((cb) => cb());
+ });
+
+ // Checkbox should now show as checked without any click
+ expect(getCheckmark()).not.toBeNull();
+ });
+});