Skip to content

fix(web-ui): SplitPane a11y, logic bugs, and test coverage#518

Merged
frankbria merged 7 commits into
mainfrom
fix/split-pane-review-507
Apr 2, 2026
Merged

fix(web-ui): SplitPane a11y, logic bugs, and test coverage#518
frankbria merged 7 commits into
mainfrom
fix/split-pane-review-507

Conversation

@frankbria

@frankbria frankbria commented Apr 1, 2026

Copy link
Copy Markdown
Owner

Follow-up to #507 — addresses all 8 issues from code review.

Fixes

# Issue Fix
1 aria-hidden on divider hid collapse buttons from screen readers Replaced with role="separator" + aria-orientation="vertical"
2 lastNonCollapsed corrupted when cross-collapsing panes Guard: only save restore point when splitPct is in valid expanded range
3 isMobile caused hydration flash Return null until matchMedia effect resolves
4 No keyboard resize (WCAG 2.1.1) Added ArrowLeft/ArrowRight on divider, 5% step, clamped
5 Collapsed positions (0/100) written to localStorage caused reload drift Don't write collapsed values; clamp readStorage to valid range
6 Missing test: reload while collapsed Added
7 Missing test: cross-collapse restore Added
8 matchMedia change event untested Added viewport-widen test via triggered handler

Test plan

  • 29 tests, all passing (up from 15)
  • No new test suite failures (npm test)
  • TypeScript build passes (npm run build)

Summary by CodeRabbit

  • New Features

    • Keyboard resizing of panes with Arrow keys; divider is now keyboard-interactive and focusable.
  • Bug Fixes

    • Defer layout until mobile detection completes to prevent hydration issues.
    • Ignore invalid persisted split values, clamp stored/default splits to bounds, and avoid writing collapsed sentinel values.
    • Prevent no-op divider clicks from committing or reopening panes.
  • Improvements

    • Better collapse/expand restore behavior, precise drag commit semantics, and hidden/stacked mobile layout handling.
    • Enhanced accessibility: semantic divider role, ARIA orientation/values/labels, and proper button markup.
  • Tests

    • Reorganized and expanded tests covering keyboard, drag, storage edge cases, accessibility, mobile layout, and regressions.

- Remove aria-hidden from divider; add role=separator + aria-orientation
- Add type="button" to collapse buttons (prevent form submission)
- Add keyboard resize via ArrowLeft/ArrowRight (WCAG 2.1.1)
- Fix lastNonCollapsed corruption when cross-collapsing panes
- Don't write 0/100 to localStorage on collapse (prevents reload drift)
- Clamp readStorage to valid expanded range [min, 100-min]
- Return null until isMobile resolves (prevent hydration flash)
- Expand tests: 29 tests covering all 8 review findings
@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Tightened persisted split bounds and ignored collapsed sentinels (0/100); made mobile detection SSR-safe by deferring render until detection settles; centralized expanded-commit logic; added keyboard resizing and ARIA to the divider; tests refactored to use a matchMedia mock with a trigger and expanded coverage.

Changes

Cohort / File(s) Summary
SplitPane Implementation
web-ui/src/components/sessions/SplitPane.tsx
readStorage now accepts min/max, clamps fallback and persisted values, and treats 0/100 as invalid; splitPct initialized with bounded read; isMobile → `boolean
SplitPane Tests
web-ui/src/__tests__/components/sessions/SplitPane.test.tsx
Replaced matchMedia mock to capture listeners and expose matchMedia.trigger(newMatches); added renderSplitPane helper that flushes effect-driven mobile detection; reorganized tests into describe groups; added extensive assertions for storage edge cases (sentinels, clamping, reload), drag/no-move semantics, keyboard resizing and restore behavior, collapse/expand regression, accessibility attributes, and mobile/desktop layout switching.

Sequence Diagram(s)

sequenceDiagram
  participant User as "User"
  participant Divider as "Divider (UI)"
  participant SplitPane as "SplitPane Component"
  participant Storage as "localStorage"
  participant MatchMedia as "window.matchMedia"

  User->>Divider: drag / click / key press
  Divider->>SplitPane: onMouseDown / onMouseMove / onMouseUp / onKeyDown / onClick
  SplitPane->>SplitPane: compute nextPercent (clamp to min/max)
  alt drag with movement
    SplitPane->>SplitPane: set livePercent, lastNonCollapsed
    SplitPane->>Storage: write expanded percent (clamped, not 0/100)
  else click without movement
    SplitPane->>SplitPane: do not commit or persist
  end

  Note over MatchMedia,SplitPane: mobile detection on mount
  MatchMedia->>SplitPane: reports match -> isMobile (boolean)
  SplitPane->>SplitPane: render stacked layout if mobile
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I nudged the pane and kept the numbers true,
no zero tricks, no hundred sneaking through.
Keys and labels now sing when I request—
stack on small, on wide the panes rest. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: accessibility fixes (a11y), logic bug fixes, and test coverage expansion for the SplitPane component, which aligns with the core objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/split-pane-review-507

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

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Review: SplitPane a11y, logic bugs, and test coverage (follow-up to #507)

All 8 issues from the previous review are addressed. The test reorganization is a genuine improvement — nested describe blocks and the renderSplitPane helper reduce boilerplate and make the suite much easier to read. Here are a few remaining observations:


1. role="separator" nesting interactive buttons

The divider div uses role="separator" (focusable, so it becomes a widget per WAI-ARIA 1.2) while also containing the two collapse <button> elements as children. WAI-ARIA prohibits interactive descendants inside a focusable separator — screen readers may suppress or misroute the buttons.

One approach is to make the collapse buttons siblings of the divider rather than children, or use role="group" / role="toolbar" for the outer container and keep role="separator" only on a non-interactive visual element. The current structure means AT users may only be able to interact with the divider via arrow keys but not reach the collapse buttons.


2. Unbounded localStorage writes on keyboard repeat

onDividerKeyDown calls writeStorage synchronously on every keydown event. If a user holds ArrowLeft, browsers fire repeat events at ~30 Hz, which means 30 localStorage writes/second. This is unlikely to cause visible problems in practice but is wasteful. Writing only on keyup (or a small debounce) would be cleaner:

const onDividerKeyUp = (e: React.KeyboardEvent) => {
  if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
    writeStorage(storageKey, splitPct); // write final position once
  }
};

And remove writeStorage from onDividerKeyDown.


3. if (isMobile === null) return null — brief blank render

Returning null until the matchMedia effect fires avoids the hydration mismatch (good), but it means the component flashes blank on every mount in CSR-only contexts. The test suite works around this with act(() => {}) in renderSplitPane.

A lighter alternative: SSR/initial render with the desktop layout as the fallback (since defaultSplit is valid) and only skip the isMobile && 'hidden' class until resolved. That keeps content visible with no layout flash:

const [isMobile, setIsMobile] = useState<boolean>(false); // desktop default

Then re-detect in the effect. This is a minor UX tradeoff (desktop-first vs blank-first); the current approach is defensible, just worth being intentional about.


4. Minor: aria-label on collapse buttons not visible in diff

The tests assert aria-label="Collapse left pane" / "Expand left pane" on the collapse buttons, but those attributes don't appear as added lines in the diff for the source component. If they were already present before this PR they'd show as context (not additions), which is fine — just flagging it in case they were accidentally omitted and tests are passing for another reason.


Summary

The core logic fixes (cross-collapse lastNonCollapsed guard, localStorage write-back suppression, readStorage clamping) are correct and well-tested. The keyboard resize implementation is solid. The main structural concern is the nested interactive children inside role="separator" — that's the one change I'd prioritize before merging if AT support is a goal.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 227-231: The separator currently always advertises
aria-valuemin={minPanePercent} and aria-valuemax={100 - minPanePercent}, which
is invalid when splitPct is collapsed (0 or 100); update the computed ARIA range
around the separator in SplitPane so it reflects the actual collapsed state: if
splitPct <= minPanePercent set aria-valuemin=0 and aria-valuemax=minPanePercent,
if splitPct >= 100 - minPanePercent set aria-valuemin=100 - minPanePercent and
aria-valuemax=100, otherwise keep aria-valuemin=minPanePercent and
aria-valuemax=100 - minPanePercent; keep aria-valuenow={splitPct} and retain
role="separator" and aria-orientation="vertical".
- Around line 137-152: Create a single helper (e.g., commitExpandedSplit) and
use it from both the keyboard handler and the drag mouse-up commit so both paths
update the same state; the helper should take the new percent (next), set
transitionEnabled.current = false, update livePercent.current and
lastNonCollapsed.current to next, clear collapsed flags via
setIsLeftCollapsed(false) and setIsRightCollapsed(false), call setSplitPct(next)
and persist with writeStorage(storageKey, next); then replace the inline commits
in onDividerKeyDown and the onMouseUp drag commit to call this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2fdf76c6-c351-4595-b164-992540df2634

📥 Commits

Reviewing files that changed from the base of the PR and between ecb2fb7 and ae3d8a6.

📒 Files selected for processing (2)
  • web-ui/src/__tests__/components/sessions/SplitPane.test.tsx
  • web-ui/src/components/sessions/SplitPane.tsx

Comment thread web-ui/src/components/sessions/SplitPane.tsx
Comment thread web-ui/src/components/sessions/SplitPane.tsx Outdated
- Extract commitExpandedSplit helper shared by drag and keyboard paths
- Fix aria-valuemin/aria-valuemax to reflect actual collapsed state range
@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Follow-up review (second commit)

The second commit (31a6599) cleanly addresses both of CodeRabbit's actionable items:

  • commitExpandedSplit helper now shared by drag and keyboard paths
  • aria-valuemin/aria-valuemax correctly reflects collapsed state (ternary now in the diff)

Reviewing the current state against my earlier comment: items 2–4 are addressed or already verified, but item 1 is still open.


Outstanding: nested interactive children inside role="separator"

The divider is <div role="separator" tabIndex={0}> containing two <button> elements. WAI-ARIA 1.2 prohibits interactive descendants inside a focusable separator — the collapse buttons may be unreachable by AT users navigating with standard keyboard interaction. This is still present in the latest commit and is the one remaining structural concern.

Everything else is solid: localStorage collapse suppression, lastNonCollapsed guard, readStorage clamping, keyboard resize, and the test reorganization (29 tests vs 15) are all correct and well-executed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
web-ui/src/components/sessions/SplitPane.tsx (2)

128-147: ⚠️ Potential issue | 🟠 Major

Skip the mouse-up commit when the divider never moved.

After a pane is collapsed, livePercent.current still contains the old expanded width. Because onMouseUp always commits it, a plain click on the divider reopens the pane even though no drag happened.

🖱️ Suggested fix
     const onMouseUp = () => {
       if (!isDragging.current) return;
       isDragging.current = false;
+      if (livePercent.current === splitPct) return;
       commitExpandedSplit(livePercent.current);
     };
@@
-  }, [minPanePercent, commitExpandedSplit]);
+  }, [minPanePercent, splitPct, commitExpandedSplit]);
 
   const onDividerMouseDown = () => {
     isDragging.current = true;
+    livePercent.current = splitPct;
     transitionEnabled.current = false;
     if (leftPaneRef.current) leftPaneRef.current.style.transition = '';
     if (rightPaneRef.current) rightPaneRef.current.style.transition = '';
   };

Also applies to: 140-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/SplitPane.tsx` around lines 128 - 147, The
mouse-up commit should be skipped when the divider wasn't actually moved: add a
ref like dragMoved/current or startPercent/current and initialize it in
onDividerMouseDown (e.g., set dragMoved.current = false and record startPercent
= livePercent.current); in the onMouseMove handler set dragMoved.current = true
(or set it when Math.abs(livePercent.current - startPercent.current) > a small
threshold) and in onMouseUp only call commitExpandedSplit(livePercent.current)
if dragMoved.current is true; update references to isDragging.current,
onDividerMouseDown, onMouseMove, and onMouseUp accordingly so a plain click does
not reopen a collapsed pane.

237-238: ⚠️ Potential issue | 🟡 Minor

Only widen the separator’s ARIA range for the true collapsed states.

<= / >= flips the announced range as soon as the pane hits minPanePercent or 100 - minPanePercent, even though ArrowLeft/ArrowRight are still clamped there. Key this off splitPct === 0 / splitPct === 100 (or the collapsed flags) instead.

♿ Suggested fix
-        aria-valuemin={splitPct <= minPanePercent ? 0 : minPanePercent}
-        aria-valuemax={splitPct >= 100 - minPanePercent ? 100 : 100 - minPanePercent}
+        aria-valuemin={splitPct === 0 ? 0 : minPanePercent}
+        aria-valuemax={splitPct === 100 ? 100 : 100 - minPanePercent}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/SplitPane.tsx` around lines 237 - 238, The
ARIA min/max logic currently uses <= / >= with splitPct which flips the
announced range prematurely; update the aria-valuemin and aria-valuemax
expressions to only widen the range when the pane is truly collapsed by checking
splitPct === 0 and splitPct === 100 (or the existing collapsed flags if present)
instead of using splitPct <= minPanePercent or >= 100 - minPanePercent; adjust
the expressions around aria-valuemin, aria-valuemax (and any references to
minPanePercent) so they return 0 when splitPct === 0 and 100 when splitPct ===
100, otherwise use minPanePercent and 100 - minPanePercent as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 151-157: The Arrow-key handler onDividerKeyDown incorrectly
reopens a pane when splitPct is at the collapsed sentinels (0 or 100); update
onDividerKeyDown to explicitly handle those sentinels: if splitPct === 0 and
e.key === 'ArrowLeft' return (no-op); if splitPct === 100 and e.key ===
'ArrowRight' return (no-op); otherwise compute delta as before and for inward
restore use commitExpandedSplit(minPanePercent) when left pane is collapsed
inward or commitExpandedSplit(100 - minPanePercent) when right pane is collapsed
inward; reference the existing symbols splitPct, minPanePercent,
commitExpandedSplit, clamp and the onDividerKeyDown function to locate where to
add these checks.

---

Duplicate comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 128-147: The mouse-up commit should be skipped when the divider
wasn't actually moved: add a ref like dragMoved/current or startPercent/current
and initialize it in onDividerMouseDown (e.g., set dragMoved.current = false and
record startPercent = livePercent.current); in the onMouseMove handler set
dragMoved.current = true (or set it when Math.abs(livePercent.current -
startPercent.current) > a small threshold) and in onMouseUp only call
commitExpandedSplit(livePercent.current) if dragMoved.current is true; update
references to isDragging.current, onDividerMouseDown, onMouseMove, and onMouseUp
accordingly so a plain click does not reopen a collapsed pane.
- Around line 237-238: The ARIA min/max logic currently uses <= / >= with
splitPct which flips the announced range prematurely; update the aria-valuemin
and aria-valuemax expressions to only widen the range when the pane is truly
collapsed by checking splitPct === 0 and splitPct === 100 (or the existing
collapsed flags if present) instead of using splitPct <= minPanePercent or >=
100 - minPanePercent; adjust the expressions around aria-valuemin, aria-valuemax
(and any references to minPanePercent) so they return 0 when splitPct === 0 and
100 when splitPct === 100, otherwise use minPanePercent and 100 - minPanePercent
as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a01f6a5f-0c21-41d4-bf7c-5bd145868947

📥 Commits

Reviewing files that changed from the base of the PR and between ae3d8a6 and 31a6599.

📒 Files selected for processing (1)
  • web-ui/src/components/sessions/SplitPane.tsx

Comment thread web-ui/src/components/sessions/SplitPane.tsx Outdated
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Third-pass review

The commitExpandedSplit refactor (shared by drag and keyboard) and the corrected aria-valuemin/max ternary have cleaned things up well. The remaining open issues from prior passes are already tracked, so this pass focuses on things not yet raised.


1. commitExpandedSplit unconditionally clears both collapsed flags

const commitExpandedSplit = useCallback(
  (next: number) => {
    ...
    setIsLeftCollapsed(false);
    setIsRightCollapsed(false);
    ...
  },
  [storageKey],
);

This is correct when called from drag-end or a restore keypress (ArrowRight from splitPct===0, ArrowLeft from splitPct===100). But it is also called from the normal keyboard step path:

const delta = e.key === 'ArrowLeft' ? -step : step;
commitExpandedSplit(clamp(splitPct + delta, minPanePercent, 100 - minPanePercent));

If one pane is collapsed and a user presses an inward key, splitPct will be at 0 or 100 and is handled by the sentinel guards — fine. But if isLeftCollapsed or isRightCollapsed somehow desync from splitPct (e.g., after rapid state batching), calling commitExpandedSplit on every normal keypress will redundantly re-fire the setIs*Collapsed(false) setters even when neither pane is collapsed. This is low-risk today but it means the collapsed flags serve a dual purpose: visual state AND restore-point guard. Clearing them unconditionally on every keyboard step makes them unreliable as a guard. A simple guard would be:

if (isLeftCollapsed || isRightCollapsed) {
  setIsLeftCollapsed(false);
  setIsRightCollapsed(false);
}

2. aria-label on collapse buttons is not confirmed in the diff

The test suite asserts:

expect(screen.getByTestId('collapse-left')).toHaveAttribute('aria-label', 'Collapse left pane');
expect(screen.getByTestId('collapse-left')).toHaveAttribute('aria-label', 'Expand left pane'); // after collapse

However, the diff for SplitPane.tsx adds aria-label only to the divider (aria-label="Resize panes"). No aria-label addition appears on either <button>. If these attributes existed before this PR, the tests would pass as-is — but then those accessibility assertions belong in a regression test suite, not as new coverage for changes in this PR. If the attributes do not yet exist in the source, the tests will fail in CI, which would surface quickly. Either way, it is worth confirming the button aria-label attributes are present in the current source file (not visible in this diff).


3. No test for the commitExpandedSplit no-drag click scenario

CodeRabbit flagged (as a duplicate note) that a plain click on the divider after collapse triggers onMouseUp → commitExpandedSplit(livePercent.current) and unexpectedly reopens the pane. That bug is real and still present. There is no test covering it: a test like the one below would demonstrate the regression and drive the fix:

it('plain click on divider does not reopen a collapsed pane', () => {
  renderSplitPane({ defaultSplit: 45 });
  fireEvent.click(screen.getByTestId('collapse-left')); // collapse
  // Simulate mousedown + mouseup with no mousemove (plain click)
  fireEvent.mouseDown(screen.getByTestId('split-pane-divider'));
  fireEvent.mouseUp(document);
  // Should still be collapsed, not restored to 45%
  expect(screen.getByTestId('split-pane-left')).toHaveStyle({ width: '0%' });
});

Adding this test first would make the plain-click fix verifiable before merge.


Summary

The core bug fixes and test reorganization are solid. The three items above are in rough priority order: item 3 (missing test for the known plain-click bug) is the most actionable before merge since it documents a known defect that will otherwise be invisible until a user reports it. Items 1 and 2 are lower risk but worth a quick look.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
web-ui/src/components/sessions/SplitPane.tsx (1)

24-35: ⚠️ Potential issue | 🟡 Minor

Clamp stored and fallback widths into the expanded range.

Line 30 drops every out-of-range stored value back to fallback, and Line 57 then seeds state with that raw defaultSplit. If minPanePercent changes or a caller passes an invalid defaultSplit, the pane can reopen at the wrong width — or outside the expanded range entirely — while both collapsed flags still read as false.

Suggested fix
 function readStorage(key: string, fallback: number, min: number, max: number): number {
+  const normalizedFallback = clamp(fallback, min, max);
   try {
     const raw = localStorage.getItem(key);
     if (raw !== null) {
-      const parsed = parseFloat(raw);
-      // Only restore valid expanded positions; ignore collapsed values (0/100)
-      if (!isNaN(parsed) && parsed >= min && parsed <= max) return parsed;
+      const parsed = Number(raw);
+      if (Number.isFinite(parsed)) {
+        if (parsed === 0 || parsed === 100) return normalizedFallback;
+        return clamp(parsed, min, max);
+      }
     }
   } catch {
     // localStorage unavailable (SSR, private browsing, etc.)
   }
-  return fallback;
+  return normalizedFallback;
 }

Also applies to: 56-58

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/components/sessions/SplitPane.tsx` around lines 24 - 35, The
stored or fallback split can exceed the allowed expanded range; update
readStorage to clamp both the parsed stored value and the fallback into the
provided min..max before returning (i.e., if parsed is valid return
Math.min(max, Math.max(min, parsed)), and if using the fallback return the
clamped fallback). Also clamp wherever the raw defaultSplit is used to seed
state in the SplitPane component (the defaultSplit/state initialization) so that
changes to minPanePercent or an invalid defaultSplit cannot open the pane
outside the expanded range.
🧹 Nitpick comments (1)
web-ui/src/__tests__/components/sessions/SplitPane.test.tsx (1)

125-130: Add a stale-width clamp case here.

This only proves the collapsed-sentinel path (0/100). It still won't catch the reload-drift case where a stored 10 or 90 should reopen at minPanePercent / 100 - minPanePercent after the bounds change. A focused 10 -> 15 and 90 -> 85 assertion would cover the missing branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/components/sessions/SplitPane.test.tsx` around lines 125
- 130, Extend the test in SplitPane.test.tsx to also cover the stale-width clamp
branch: set localStorageMock.setItem('split-pane-position', '10') and render via
renderSplitPane with minPanePercent set to 15 (and defaultSplit as appropriate)
and assert the left pane width becomes '15%'; repeat symmetrically with stored
'90' and minPanePercent 15 to assert the left pane width becomes '85%'. Use the
same test harness (renderSplitPane, screen.getByTestId('split-pane-left')) so
these assertions exercise the reload-drift clamping code path in the SplitPane
component.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 24-35: The stored or fallback split can exceed the allowed
expanded range; update readStorage to clamp both the parsed stored value and the
fallback into the provided min..max before returning (i.e., if parsed is valid
return Math.min(max, Math.max(min, parsed)), and if using the fallback return
the clamped fallback). Also clamp wherever the raw defaultSplit is used to seed
state in the SplitPane component (the defaultSplit/state initialization) so that
changes to minPanePercent or an invalid defaultSplit cannot open the pane
outside the expanded range.

---

Nitpick comments:
In `@web-ui/src/__tests__/components/sessions/SplitPane.test.tsx`:
- Around line 125-130: Extend the test in SplitPane.test.tsx to also cover the
stale-width clamp branch: set localStorageMock.setItem('split-pane-position',
'10') and render via renderSplitPane with minPanePercent set to 15 (and
defaultSplit as appropriate) and assert the left pane width becomes '15%';
repeat symmetrically with stored '90' and minPanePercent 15 to assert the left
pane width becomes '85%'. Use the same test harness (renderSplitPane,
screen.getByTestId('split-pane-left')) so these assertions exercise the
reload-drift clamping code path in the SplitPane component.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1c7ad836-e14f-428f-afd9-0fed0b6fb360

📥 Commits

Reviewing files that changed from the base of the PR and between 31a6599 and b045a8d.

📒 Files selected for processing (2)
  • web-ui/src/__tests__/components/sessions/SplitPane.test.tsx
  • web-ui/src/components/sessions/SplitPane.tsx

- readStorage: clamp fallback and stored values into [min,max]; reject
  collapsed sentinels (0/100) explicitly via Number.isFinite path
- onDividerMouseDown: reset livePercent to splitPct and dragMoved=false
- onMouseUp: only call commitExpandedSplit when dragMoved — plain clicks
  on divider no longer reopen collapsed panes
- ARIA range: use splitPct===0/===100 (not <=/>= minPanePercent)
- Tests: stale-width clamp (10→15, 90→85), invalid defaultSplit clamp,
  plain-click no-op on collapsed pane (37 tests total)
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Fourth-pass review

Building on my prior third-pass comment — reviewing the new changes in this commit.


Fixed: plain-click bug (item 3 from third pass)

The dragMoved ref approach is clean and correct:

  • onDividerMouseDown resets dragMoved.current = false and snapshots livePercent.current = splitPct
  • onMouseMove sets dragMoved.current = true
  • onMouseUp only calls commitExpandedSplit when dragMoved.current is true

The corresponding test (plain click on divider does not reopen a collapsed pane) verifies both that storage is untouched and that the pane stays collapsed. This was the highest-priority item from last pass — good to see it closed.


Still open from previous passes

Item 1 (low risk): commitExpandedSplit still unconditionally fires setIsLeftCollapsed(false) and setIsRightCollapsed(false) on every normal keyboard step, even when neither pane is collapsed. No practical bug today, but guarding on the actual state would make the intent clearer:

if (isLeftCollapsed || isRightCollapsed) {
  setIsLeftCollapsed(false);
  setIsRightCollapsed(false);
}

Item 2 (informational): aria-label on the collapse buttons is not visible as an addition in this PR's diff. The tests assert these attributes and pass, which means they were present before this PR — no action needed.

Structural concern (raised in first pass, still open): The focusable role="separator" div still contains the two collapse <button> children. WAI-ARIA 1.2 prohibits interactive descendants inside a focusable separator widget. If AT support is a goal, this remains the primary concern before merge.


Summary

The plain-click fix is the right approach and well-tested. The PR is in good shape for the scope it targets. The nested-interactive-inside-separator structure is the one remaining item worth resolving before merge if accessibility is a hard requirement.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
web-ui/src/__tests__/components/sessions/SplitPane.test.tsx (1)

26-29: Keep the new MatchMediaHandler alias in the shared types module.

This adds another project-defined type inside web-ui/src. Move it to web-ui/src/types/index.ts so the tests stay aligned with the repo’s type-location rule.

As per coding guidelines, "TypeScript types must be defined in web-ui/src/types/index.ts."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/__tests__/components/sessions/SplitPane.test.tsx` around lines 26
- 29, The new MatchMediaHandler type is defined in the test file; move it into
the shared types module (types/index.ts) so it follows the repo rule. Add and
export the alias MatchMediaHandler from types/index.ts, remove the local
declaration from SplitPane.test.tsx, and import MatchMediaHandler into the test;
verify mockMatchMedia still uses the imported MatchMediaHandler and that
mockMatchMedia remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 152-156: The divider's onDividerMouseDown currently starts a drag
for any mousedown on the divider wrapper, which lets clicks on the collapse
button bubble into the drag path; change onDividerMouseDown to accept the
MouseEvent and return early when the event originated from the collapse button
(e.g. check event.target.closest('button') or a specific collapse-button
class/data attribute) or when event.button !== 0, so button clicks don't begin a
drag. Update the divider wrapper's onMouseDown binding to pass the event to
onDividerMouseDown and keep the existing behavior for real drags
(isDragging.current, dragMoved.current, livePercent.current,
transitionEnabled.current) unchanged.
- Around line 123-141: The mouse drag path currently clamps outward drags from a
fully collapsed pane into the valid range and then commits them on mouseup;
update onMouseMove and onMouseUp so drags that start at or remain at the extreme
edges are treated as no-ops: detect the starting edge collapse
(livePercent/current width at 0 or 100 or container edge hit) and if the current
mouse movement would move further outward (left from 0 or right from 100) do not
update livePercent, do not change leftPaneRef/rightPaneRef widths, and do not
set dragMoved; only set dragMoved and call
commitExpandedSplit(livePercent.current) when the pointer actually moves inward
enough to exit the collapsed state. Keep references to onMouseMove, onMouseUp,
isDragging, dragMoved, livePercent, commitExpandedSplit, clamp, minPanePercent,
containerRef, leftPaneRef and rightPaneRef when implementing and add regression
tests covering both left-collapsed and right-collapsed outward drags.

---

Nitpick comments:
In `@web-ui/src/__tests__/components/sessions/SplitPane.test.tsx`:
- Around line 26-29: The new MatchMediaHandler type is defined in the test file;
move it into the shared types module (types/index.ts) so it follows the repo
rule. Add and export the alias MatchMediaHandler from types/index.ts, remove the
local declaration from SplitPane.test.tsx, and import MatchMediaHandler into the
test; verify mockMatchMedia still uses the imported MatchMediaHandler and that
mockMatchMedia remains unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f0055c6d-b5f3-4281-bd84-6315b46f5523

📥 Commits

Reviewing files that changed from the base of the PR and between b045a8d and 2e62be3.

📒 Files selected for processing (2)
  • web-ui/src/__tests__/components/sessions/SplitPane.test.tsx
  • web-ui/src/components/sessions/SplitPane.tsx

Comment thread web-ui/src/components/sessions/SplitPane.tsx
Comment thread web-ui/src/components/sessions/SplitPane.tsx Outdated
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Fourth-pass review

The latest commit addresses the three open items from my third-pass review and CodeRabbit's duplicate comments:

  • Resolved: Plain-click no-reopen bug — dragMoved.current + livePercent.current = splitPct reset in onDividerMouseDown is the right fix. The e.target !== e.currentTarget guard also prevents button-click bubbles from accidentally starting a drag.
  • Resolved: Plain-click test — Added and correctly asserts storage stays null after mousedown/mouseup with no mousemove.
  • Resolved: Keyboard sentinel handling — splitPct === 0 / splitPct === 100 guards are precise (no premature <=/>=).
  • Resolved: readStorage clamping — Now normalises the fallback and stored values into [min, max], with correct sentinel rejection. CodeRabbit's outside-diff note on this is resolved in the current state.
  • Resolved: Stale-width clamp tests — 10 to 15% and 90 to 85% assertions cover the boundary cases.

Still open

role="separator" with interactive button children — this concern has been raised in every pass and is still present in the latest diff. The divider is <div role="separator" tabIndex={0}> containing two <button> elements. WAI-ARIA 1.2 §3.15 prohibits owned interactive descendants in a focusable separator widget; AT users navigating by role may be unable to reach the collapse buttons.

The fix is structural: move the collapse buttons outside the separator element. A wrapping role="group" container with the separator as a visual-only sibling would resolve this correctly.

This is the one remaining issue I would want resolved before merging if AT support is a stated goal of this PR.


Minor (non-blocking)

Unbounded localStorage writes on keyboard holdonDividerKeyDown still writes storage on every repeat event (approximately 30/s when a key is held). Moving writeStorage to a keyup handler would eliminate the churn with no visible UX change. Not a correctness issue, just unnecessary work.


Everything else is in good shape. The 29-test suite, commitExpandedSplit refactor, localStorage sentinel/clamping logic, and mobile detection are all correct and well-covered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web-ui/src/components/sessions/SplitPane.tsx`:
- Around line 122-141: The drag path reopens collapsed panes because onMouseMove
always sets dragMoved and livePercent even when the pointer moves further
outward; modify the logic in onMouseMove (and the mousedown handler that starts
dragging) to record the initial mouse X (e.g., dragStartXRef) when isDragging
becomes true and, if splitPct is 0 or 100, treat outward movement as no-op: only
set dragMoved.current = true and update livePercent.current when the pointer
moves inward toward the center beyond a small threshold (e.g., >4px) — keep
using containerRef to compute clamped percent and call
commitExpandedSplit(livePercent.current) on mouseup only when dragMoved.current
is true; leave other behaviors (isDragging, dragMoved reset,
leftPaneRef/rightPaneRef width updates) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4557baae-376c-4dfc-8a96-ac11de195d07

📥 Commits

Reviewing files that changed from the base of the PR and between 2e62be3 and 9dba993.

📒 Files selected for processing (1)
  • web-ui/src/components/sessions/SplitPane.tsx

Comment thread web-ui/src/components/sessions/SplitPane.tsx
Moves dragged from a fully-collapsed edge (splitPct 0 or 100) that keep
the pointer at or beyond the same edge no longer set dragMoved or update
pane widths; only inward movement that crosses back inside the container
exits the collapsed state and triggers commitExpandedSplit.

Adds four regression tests covering both left- and right-collapsed
outward and inward drag paths.
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Third-pass review

Following up on my earlier reviews: the CodeRabbit actionable items from the second commit are verified fixed. All core logic (localStorage clamping, lastNonCollapsed guard, commitExpandedSplit shared path, ARIA value ranges) is correct.


Still outstanding: nested interactive buttons inside role=separator

WAI-ARIA 1.2 specifies that a focusable separator is a widget and must not contain interactive descendants. The current structure has role="separator" tabIndex={0} wrapping the two collapse buttons -- AT will either suppress the buttons or exhibit undefined navigation behavior.

Suggested fix: make the resize handle and the collapse controls siblings rather than parent/child. Position the buttons absolutely over the divider area so the visual result is identical, but Tab/Shift-Tab reach the separator and the collapse buttons as independent focusable elements.

Everything else in this PR is solid and ready to merge once this structural a11y issue is resolved.

A focusable separator is a widget and must not contain interactive
descendants. Wraps the resize handle and both collapse buttons in a
sibling-level track div; role="separator" now has no children so AT
reaches all three focusable elements independently.

Visual output is unchanged — buttons remain absolutely positioned over
the same pixel area. Updates the mobile-hidden test to target the track
instead of the separator.
@frankbria frankbria merged commit be2fd2b into main Apr 2, 2026
10 checks passed
@frankbria frankbria deleted the fix/split-pane-review-507 branch April 2, 2026 01:20
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Follow-up Review - picking up from my previous comments.

Issue 1 role=separator nesting: RESOLVED. The separator is now a self-closing div and the collapse buttons are proper siblings inside split-pane-divider-track. The inline WAI-ARIA 6.8 reference in the comment is a nice touch.

Issue 2 Unbounded localStorage writes on keyboard repeat: STILL OPEN. onDividerKeyDown calls commitExpandedSplit (which calls writeStorage) on every keydown, including keyboard repeats. Holding an arrow key fires around 30 events per second, so 30 localStorage writes per second while resizing via keyboard. The fix from my previous review still applies: move the storage write to a keyup handler and remove writeStorage from commitExpandedSplit. Drag-end via mouse already persists from onMouseUp.

Issue 3 isMobile null flash: ACKNOWLEDGED. Still the blank-until-detected approach. As noted previously, this is a minor UX tradeoff. No change needed unless a blank flash is observed in practice.

Issue 4 aria-label on collapse buttons: CONFIRMED PRESENT. Tests assert the aria-labels and are passing, so the attributes are in the component even though they are not visible as added lines in the diff (pre-existing context).

New observation - e.target !== e.currentTarget guard on separator mousedown: Now that the collapse buttons are siblings of the separator (not children), click events from those buttons cannot bubble to the separator mousedown handler anyway. The guard can never fire in practice. Harmless, but could be removed or replaced with a clarifying comment.

Summary: The structural a11y fix (issue 1) is correct and the code is clearly better than before. The main open item before merging is issue 2 (keyboard storage debounce).

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.

1 participant